---
openapi: 3.0.3
info:
  title: MangoApps API
  description: |
    # MangoApps API Documentation

    This API provides access to the MangoApps platform, allowing you to:
    - Manage shifts and schedules
    - Get notified of changes via webhooks
    - Upload and retrieve demand forecasts
    - Perform bulk operations on shifts, assignments, and more
    - Mobile authentication and user management

    ## Authentication

    The API supports multiple authentication methods:

    ### Mobile App Authentication
    ```
    POST /api/v1/auth/login
    {
      "email": "user@example.com",
      "password": "password",
      "otp_code": "123456" // Optional, required if 2FA is enabled
    }
    ```

    Response includes `access_token` and `refresh_token` for subsequent requests.

    ### API Token Authentication
    Include the token in the Authorization header:
    ```
    Authorization: Bearer YOUR_API_TOKEN
    ```

    ### Service Account Authentication
    For service accounts, include both token and secret:
    ```
    Authorization: Bearer YOUR_TOKEN
    X-API-Secret: YOUR_SECRET
    ```

    Service accounts with the `admin` scope have full access to all API endpoints and bypass
    individual scope checks. Other service accounts require specific scopes (e.g., `read:shifts`,
    `write:attendance`) for each endpoint.

    ### Impersonation Token Authentication
    Service accounts with `impersonate` or `admin` scope can create short-lived impersonation
    tokens to act on behalf of specific users. This is useful for accessing user-specific data
    (e.g., leave balances, personal preferences) without creating separate admin endpoints.

    To use impersonation:
    1. Create an impersonation token using your service account:
       ```
       POST /api/v1/auth/impersonate
       Authorization: Bearer YOUR_SERVICE_ACCOUNT_TOKEN
       X-API-Secret: YOUR_SERVICE_ACCOUNT_SECRET
       {
         "user_id": 123
       }
       ```
    2. Use the returned impersonation token for subsequent API calls:
       ```
       Authorization: Bearer IMPERSONATION_TOKEN
       ```

    Impersonation tokens:
    - Are short-lived (default: 1 hour)
    - Can be revoked at any time
    - Automatically log all access for audit purposes
    - Allow access to user-specific endpoints as if authenticated as that user

    An impersonation token can never change how the person signs in or recovers their account: changing the
    password, the sign-in phone, email or User ID, two-factor authentication or backup codes, API tokens, sessions
    or security settings answers `403` with error code `impersonation_cannot_change_account_security`, and the
    person must make that change from their own sign-in. While impersonation target checks are enabled for the
    installation, creating an impersonation token answers `403` with error code `impersonation_target_not_allowed`
    when the person may not be impersonated through that token: they are a Super Admin in another organization
    where the token's owner isn't one, they also belong to another organization the owner doesn't administer, or
    they are a Super Admin here and the owner isn't; the error `message` says which. The same checks run on every
    request of an open impersonation session, so a token whose person would now be refused stops authenticating
    (`401`). While the checks are disabled, none of these refusals applies.

    ## Rate Limiting
    Each API token may make 5,000 requests per hour, of which 5,000 may be searches and
    1,000 uploads. Authentication calls are limited to 2,000 per hour for each network
    address, and requests with no valid token share 10,000 per hour for each network address.
    Narrower limits apply to some costly endpoints.

    Exceeding a limit answers `429`. Every 429 carries `Retry-After`, the seconds to wait.
    The platform's limits use error code `rate_limited`; a feature with a limit of its own
    can use its own code. Rate limit headers are included in responses, for the limit you
    will reach first:
    - `X-RateLimit-Limit`: Request limit per window
    - `X-RateLimit-Remaining`: Requests remaining in current window
    - `X-RateLimit-Reset`: Unix timestamp when window resets

    Wait for `Retry-After` before sending the call again. A client that keeps calling while
    refused can be refused for longer, by the load balancer or by thread time used.

    ## API Allowance (402)
    Every call made with an API token someone created (a personal, admin, service-account or CLI
    token) counts against the organization's monthly API allowance, then against purchased API
    credits. Once both are used up, each such call answers `402` with error code
    `api_credits_exhausted` (response `ApiCreditsExhausted`) on every endpoint but `POST /auth/logout`
    (signing out is never refused), until the allowance resets on the 1st, credits are bought, or
    auto top-up buys more (Admin → Billing & Payments → Credits).
    Enterprise plans are not limited. Calls from a signed-in browser session and from the MangoApps
    apps' own sign-in are neither counted nor refused, and refused calls (`401`, `402`, `403`, `429`)
    and server errors are not counted.

    ## Pagination
    Collections return pagination metadata and headers:
    - Query parameters: `page`, `per_page`
    - Response headers: `X-Total-Count`, `X-Total-Pages`, `X-Current-Page`, `X-Per-Page`

    ## Error Handling
    Errors follow a consistent format:
    ```json
    {
      "error": {
        "code": "error_code",
        "message": "Human readable message",
        "details": {} // Optional additional context
      }
    }
    ```

    Validation errors return:
    ```json
    {
      "errors": [
        {
          "field": "email",
          "message": "Email is required",
          "code": "validation_error"
        }
      ]
    }
    ```

    ## HTTP Protocol Enhancements

    The API includes comprehensive HTTP protocol enhancements for optimal client performance:

    ### Intelligent Caching
    - **ETags**: Content-based versioning for efficient cache validation
    - **Cache-Control**: Fine-grained cache behavior control
    - **Conditional Requests**: Support for If-None-Match headers (304 responses)
    - **Vary Headers**: Content negotiation awareness

    ### Dynamic Navigation
    - **Next Actions**: Server-suggested user actions based on context
    - **Preload Hints**: Resources to preload for better performance
    - **Conditional Redirects**: Dynamic flow control based on user state
    - **Link Relations**: Standard HTTP Link headers for navigation

    ### Performance Optimization
    - **Response Compression**: Automatic gzip/deflate compression
    - **Response Timing**: Server processing metrics
    - **Cache Status**: Hit/miss reporting for optimization
    - **Size Estimation**: Bandwidth usage awareness

    ### Base Path and Versioning
    - All endpoint paths in this specification are relative. The `/api/v1` base path is provided by the `servers` section below.
    - When calling the API, combine the server URL with the relative path. For example: `https://dev.workforce.mangoapps.com/api/v1` + `/shifts` → `https://dev.workforce.mangoapps.com/api/v1/shifts`.
    - Do not include `/api/v1` in individual path keys; it is already defined at the server level for consistency across environments.

    ### Client Hints & Reliability
    - **Rate Limiting**: Request quota and reset information (429 status)
    - **API Versioning**: Version compatibility information
    - **Deprecation Warnings**: Graceful API evolution notices
    - **Capability Detection**: Client feature detection
    - **Idempotency Support**: Duplicate request prevention with Idempotency-Key
    - **Conditional Requests**: 304 Not Modified for cached content

    ### Example Enhanced Response Headers
    ```http
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    Content-Encoding: gzip
    ETag: W/"abc123-def456"
    Cache-Control: private, max-age=300, must-revalidate
    Vary: Accept, Authorization, X-App-Platform
    Link: </api/v1/shifts?status=upcoming>; rel="preload"; as="fetch"
    X-Response-Time: 45.2ms
    X-RateLimit-Remaining: 4998
    X-RateLimit-Reset: 1640995200
    X-RateLimit-Limit: 5000
    X-Idempotency-Cached: false
    X-API-Version: 1.0
    ```

    These enhancements provide:
    - **50-80% bandwidth reduction** with compression
    - **60-90% cache hit rates** with intelligent ETags
    - **2-3x faster navigation** with preload hints
    - **Reduced API calls** with conditional requests
  version: 1.0.0
  contact:
    name: MangoApps API Support
    email: api-support@workforce.mangoapps.com
  license:
    name: Proprietary
servers:
- url: https://api.workforce.mangoapps.com/api/v1
  description: Production - Common API
- url: https://dev.workforce.mangoapps.com/api/v1
  description: Development - Common API
- url: https://{business}.workforce.mangoapps.com/api/v1
  description: Production - Business API
  variables:
    business:
      default: demo
      description: Business subdomain (e.g., officechat, acmecorp)
      examples:
      - officechat
      - acmecorp
      - retailstore
- url: https://{business}.dev.workforce.mangoapps.com/api/v1
  description: Development - Business API
  variables:
    business:
      default: demo
      description: Business subdomain for development
security:
- BearerAuth: []
- ServiceAccountAuth: []
paths:
  "/changelog_entries":
    get:
      tags:
      - Changelog
      summary: List changelog entries
      description: "Retrieve a list of changelog entries. This endpoint is for internal
        use only \nand requires either internal API credentials or a changelog secret.\n"
      security:
      - InternalAPI: []
      - ChangelogSecret: []
      responses:
        '200':
          description: List of changelog entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChangelogEntry"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 25
                      published_count:
                        type: integer
                        example: 20
                      latest_version:
                        type: string
                        example: '1.6'
        '401':
          "$ref": "#/components/responses/Unauthorized"
    post:
      tags:
      - Changelog
      summary: Create changelog entry
      description: "Create a new changelog entry. This endpoint is for internal use
        only \nand requires either internal API credentials or a changelog secret.\n"
      security:
      - InternalAPI: []
      - ChangelogSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - changelog_entry
              properties:
                changelog_entry:
                  "$ref": "#/components/schemas/ChangelogEntryInput"
      responses:
        '201':
          description: Changelog entry created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Changelog entry created successfully
                  data:
                    "$ref": "#/components/schemas/ChangelogEntryResponse"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  message:
                    type: string
                    example: Failed to create changelog entry
                  errors:
                    type: array
                    items:
                      type: string
                    example:
                    - Title can't be blank
                    - Content can't be blank
  "/businesses/by_email":
    post:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Get businesses by user email
      description: |
        Find the tenants a user's email address can sign in to. This backs the
        workspace picker after mobile's email-entry screen, and is the JSON twin
        of the web `POST /find_companies` flow.

        Prefer POST over the GET below: the query-string form writes the address
        into access logs and error-tracker breadcrumbs.

        Throttled at 20 requests/minute per IP and 10/minute per email address.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              properties:
                email:
                  type: string
                  format: email
                  example: user@officechat.com
      responses:
        '200':
          description: |
            Lookup completed. An empty `businesses` array means the address is
            unknown OR has no active tenants — the two are not distinguished.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BusinessDiscoveryResult"
        '400':
          description: Missing or blank email
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate limit exceeded
    get:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Get businesses by user email (query-string form)
      deprecated: true
      description: |
        Kept for existing clients. Use `POST /businesses/by_email` instead — a
        query string puts the email address into access logs and error-tracker
        breadcrumbs. Identical behaviour and response otherwise.
      security: []
      parameters:
      - name: email
        in: query
        required: true
        description: User's email address
        schema:
          type: string
          format: email
          example: user@officechat.com
      responses:
        '200':
          description: |
            Lookup completed. An empty `businesses` array means the address is
            unknown OR has no active tenants — the two are not distinguished.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BusinessDiscoveryResult"
        '400':
          description: Missing or blank email
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate limit exceeded
  "/businesses/lookup":
    get:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Search businesses by company name
      description: |
        Search for businesses using partial or full company name matching.
        Returns up to 10 matching businesses. Matching ignores case AND
        punctuation on both sides — spaces, apostrophes, hyphens, periods —
        so `Oreilly`, `O'Reilly` and `oreilly auto parts` all find
        "O'Reilly Auto Parts". A term containing no letter or digit at all
        (e.g. `'''`) is rejected with `missing_company_name`, exactly like an
        absent parameter. Useful for company name lookup and business
        directory features.
      security: []
      parameters:
      - name: company_name
        in: query
        required: true
        description: Full or partial company name to search for
        schema:
          type: string
          example: Office Chat
      responses:
        '200':
          description: Businesses found
          content:
            application/json:
              schema:
                type: object
                properties:
                  businesses:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        name:
                          type: string
                          example: Office Chat Solutions
                        subdomain:
                          type: string
                          example: officechat
                        logo_url:
                          type: string
                          nullable: true
                          description: |
                            Absolute logo URL, or null when unattached. Served from
                            the matched tenant's OWN host — this endpoint searches
                            across tenants, so it is not the requesting host.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/logo.png
                        api_base_url:
                          type: string
                          description: |
                            Origin the client should send the subsequent login request
                            to, and the disambiguator to show under each company name
                            in a search list. Identical to the `api_base_url` that
                            `/businesses/by_email` returns for the same business `id`
                            — both endpoints render through one serializer. Already
                            carries the environment suffix (-dev / -qa / -staging;
                            none in production), so clients must not rebuild the host
                            from `subdomain` themselves.
                          example: https://officechat.workforce.mangoapps.com
                        timezone:
                          type: string
                          example: America/New_York
                        industry:
                          type: string
                          description: |
                            Tenant industry, or an empty string when unset — it is
                            emitted as "" rather than omitted, so treat empty as
                            absent. Not a reliable subtitle; use `api_base_url`.
                          example: ''
                        hosted_elsewhere:
                          type: boolean
                          description: |
                            Present (true) only for a workspace hosted on another
                            MangoApps deployment, such as Europe. Its `id` is its id
                            there, `api_base_url` is where to sign in, `logo_url`
                            is null and `timezone` is absent. This deployment's own
                            workspaces come first.
                          example: true
                        deployment:
                          type: string
                          description: Present only with `hosted_elsewhere`. The deployment's
                            label, e.g. `eu`.
                          example: eu
                  total_count:
                    type: integer
                    example: 2
                    description: Number of businesses found (max 10)
                  search_term:
                    type: string
                    example: Office Chat
                    description: The search term used
        '400':
          description: Missing company name parameter
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No businesses found matching the search term
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/businesses/validate_subdomain":
    get:
      servers:
      - url: https://api.workforce.mangoapps.com/api/v1
        description: Production - Common API
      - url: https://dev.workforce.mangoapps.com/api/v1
        description: Development - Common API
      tags:
      - Business Discovery
      summary: Validate business subdomain exists
      description: Check if a business subdomain is available and valid
      security: []
      parameters:
      - name: subdomain
        in: query
        required: true
        description: Business subdomain to validate
        schema:
          type: string
          example: officechat
      responses:
        '200':
          description: Validation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  exists:
                    type: boolean
                    example: true
                  subdomain:
                    type: string
                    example: officechat
                  business_name:
                    type: string
                    example: Office Chat Solutions
                  hosted_elsewhere:
                    type: boolean
                    description: |
                      Present (true) only when the workspace is hosted on another
                      MangoApps deployment, such as Europe. `exists` stays false —
                      it means "on this deployment" — and `api_base_url` is where
                      to sign in.
                    example: true
                  api_base_url:
                    type: string
                    description: |
                      The origin to sign in at, when the workspace exists here or
                      is hosted elsewhere. Absent when it is unknown.
                    example: https://acme.onemango.com
        '400':
          description: Missing subdomain parameter
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/login":
    post:
      tags:
      - Authentication
      summary: Login for mobile apps
      description: "Authenticate user and return access/refresh tokens.\n\n**Session
        Cookie Support:** In addition to returning API tokens, this endpoint \nalso
        establishes a Rails session and sets a session cookie (`_workforce_session_*`).
        \nThis allows the session cookie to be used for web page access without requiring
        \na separate web login. The session cookie is automatically set by the server
        and \nmanaged by HTTP clients.\n\n**Use Cases:**\n- Mobile apps: Use the access_token
        for API calls\n- Web views in mobile apps: The session cookie enables seamless
        web page access\n- Hybrid apps: Use either API tokens or session cookie as
        needed\n"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              - password
              properties:
                email:
                  type: string
                  format: email
                  example: user@example.com
                password:
                  type: string
                  format: password
                  example: password123
                otp_code:
                  type: string
                  description: Two-factor authentication code (required if 2FA enabled)
                  example: '123456'
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                    description: JWT access token
                  refresh_token:
                    type: string
                    description: Refresh token for getting new access tokens
                  expires_in:
                    type: integer
                    description: Access token expiry in seconds
                  token_type:
                    type: string
                    example: Bearer
                  user:
                    "$ref": "#/components/schemas/User"
                  business:
                    "$ref": "#/components/schemas/Business"
                  _meta:
                    "$ref": "#/components/schemas/HttpEnhancementMeta"
        '401':
          description: Authentication failed
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: 'account_locked: too many failed sign-in attempts
                            (each wrong password counts); no tokens are issued'
                          enum:
                          - two_factor_required
                          - invalid_otp
                          - invalid_credentials
                          - account_locked
                        message:
                          type: string
                        details:
                          type: object
                          properties:
                            requires_2fa:
                              type: boolean
        '403':
          description: |
            The credentials were right and no tokens are issued until the person finishes a step on the web.
            two_factor_setup_required: the organization requires two-factor authentication of this person
            (everyone, or their role) and they have not set it up, or they started setting it up and never
            confirmed it. Open details.setup_url (the organization's web sign-in, which leads to setup),
            then sign in again; the next sign-in asks for otp_code.
            password_expired: the password is past the organization's expiry period; details.change_url
            is where it is changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - two_factor_setup_required
                        - password_expired
                      message:
                        type: string
                      details:
                        type: object
                        properties:
                          requires_2fa_setup:
                            type: boolean
                          setup_url:
                            type: string
                          change_url:
                            type: string
  "/auth/hub_sso":
    post:
      tags:
      - Authentication
      summary: Sign in with a MangoApps Hub token (mobile apps)
      description: |
        Exchanges a signed MangoApps Hub token for access and refresh tokens, like /auth/login.
        A locked account (too many failed sign-in attempts) is refused with account_locked, and no tokens are issued.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - token
              properties:
                token:
                  type: string
                  description: Signed MangoApps Hub token
                business_id:
                  type: integer
                  description: Organization to sign in to
      responses:
        '200':
          description: Signed in. The same token fields as /auth/login, plus sso_provider.
        '401':
          description: Sign-in refused
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - no_token
                        - not_configured
                        - invalid_token
                        - expired_token
                        - nonce_reused
                        - domain_mismatch
                        - user_deactivated
                        - jit_disabled
                        - jit_domain_blocked
                        - provisioning_failed
                        - service_account_blocked
                        - account_locked
                      message:
                        type: string
                      details:
                        type: object
                        properties:
                          suggestion:
                            type: string
  "/auth/login_config":
    get:
      tags:
      - Authentication
      - Mobile
      summary: Get login configuration for business
      description: |
        Returns complete login configuration for a business including available
        authentication methods, SSO providers, security settings, and API endpoints.
        This endpoint provides all information needed to render a mobile login screen.

        SSO Provider Support:
        - Multiple SSO providers of the same type are supported (e.g., multiple Google OAuth2 configs)
        - Each provider has a unique ID that should be used as the `provider_id` parameter
        - Use `channel_support` and `mobile_supported`/`web_supported` to filter providers appropriate for the client
      security: []
      parameters:
      - name: business_id
        in: query
        required: true
        schema:
          type: integer
        description: Business ID to get login configuration for
        example: 123
      responses:
        '200':
          description: Login configuration retrieved successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoginConfiguration"
        '404':
          description: Business not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/sso/initiate":
    post:
      tags:
      - Authentication
      - SSO
      - Mobile
      summary: Initiate SSO authentication flow
      description: |
        Initiates SSO authentication flow for mobile applications. Returns an authorization
        URL that should be opened in a web view or external browser. The mobile app should
        handle the callback and extract the authorization code for token exchange.

        Provider Selection:
        1. Call `/auth/login_config` to get available SSO providers
        2. Display providers to user (multiple providers of same type may exist)
        3. Use the selected provider's `id` as the `provider_id` in this request
        4. For mobile, choose providers with `mobile_supported: true`
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - business_id
              - provider_id
              properties:
                business_id:
                  type: integer
                  description: Business ID for SSO authentication
                  example: 123
                provider_id:
                  type: string
                  description: SSO provider ID from login configuration
                  example: '2'
                redirect_uri:
                  type: string
                  description: Optional redirect URI (HTTPS URL for Universal Links/App
                    Links - Google OAuth does not support custom URL schemes)
                  example: https://workforce.mangoapps.com/mobile/sso-callback
                app_redirect_uri:
                  type: string
                  description: |
                    Desktop only. Where the callback page sends the browser once the IdP is
                    finished — never sent to the IdP itself. Accepted forms are a registered
                    custom scheme (`mangoappsdesktop://`, `mangoappsmessengerschema://`) or a
                    loopback callback the app serves itself: `http://127.0.0.1:<port>/sso-callback`
                    or `http://[::1]:<port>/sso-callback`, with no query, fragment, or credentials
                    and a port in 1-65535. Anything else is dropped. Check the echoed
                    `app_redirect_uri` in the response to see whether the value survived.
                  example: http://127.0.0.1:52341/sso-callback
                code_challenge:
                  type: string
                  description: |
                    The app's own PKCE challenge (RFC 7636, RFC 8252 §8.1): the base64url
                    SHA-256 (43 characters, no padding) of a random `code_verifier` the app
                    keeps and presents to `/auth/sso/exchange`. The server stores only the
                    challenge, bound to the returned `state`. Optional today; once the
                    platform requires it, a request without one is refused with
                    `pkce_required`.
                  example: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
                code_challenge_method:
                  type: string
                  enum:
                  - S256
                  description: Only `S256` is accepted. Defaults to `S256` when a
                    challenge is sent.
      responses:
        '200':
          description: SSO flow initiated successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SsoInitiateResponse"
        '400':
          description: |
            Invalid request parameters. `pkce_required` — the platform requires the app's
            `code_challenge` and none was sent. `invalid_pkce` — the challenge is not a
            43-character base64url SHA-256, or the method is not `S256`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Business or SSO provider not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: SSO provider temporarily unavailable
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/sso/exchange":
    post:
      tags:
      - Authentication
      - SSO
      - Mobile
      summary: Exchange authorization code for access tokens
      description: "Exchanges the authorization code received from SSO provider for
        access and refresh tokens.\nThis completes the SSO authentication flow and
        returns tokens that can be used for API access.\n\n**Session Cookie Support:**
        In addition to returning API tokens, this endpoint \nalso establishes a Rails
        session and sets a session cookie (`_workforce_session_*`). \nThis allows
        the session cookie to be used for web page access without requiring \na separate
        web login. The session cookie is automatically set by the server and \nincluded
        in the response headers - no client-side code changes are needed to receive
        it.\n\n**Use cases for session cookie:**\n- Web views in mobile apps: The
        session cookie enables seamless web page access\n- Hybrid apps: Use either
        API tokens or session cookie as needed\n- Native mobile + web integration:
        Access web content without re-login prompts\n"
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - business_id
              - provider_id
              - code
              - state
              properties:
                business_id:
                  type: integer
                  description: Business ID for SSO authentication
                  example: 123
                provider_id:
                  type: string
                  description: SSO provider ID from login configuration
                  example: '2'
                code:
                  type: string
                  description: Authorization code from SSO provider callback
                  example: 4/0AX4XfWjE...
                state:
                  type: string
                  description: State parameter from SSO initiate response
                  example: abc123def456
                code_verifier:
                  type: string
                  description: |
                    The PKCE verifier (43-128 characters of `A-Z a-z 0-9 - . _ ~`) whose S256
                    challenge the app sent to `/auth/sso/initiate`. Required whenever the
                    initiate request carried a `code_challenge`; a missing or wrong verifier
                    is refused with `invalid_grant` and the state is spent.
                  example: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
      responses:
        '200':
          description: SSO authentication completed successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SsoExchangeResponse"
        '400':
          description: |
            `invalid_state` — the state is unknown, expired or already used, or it
            was issued for another business or another provider (a state is
            bound to the `business_id` AND `provider_id` it was initiated with;
            restart the flow). `invalid_grant` — the state was issued with a PKCE
            `code_challenge` and the `code_verifier` is missing or does not match
            it. `token_exchange_failed` — the provider refused
            the code. `missing_parameters` — a required field is blank.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: |
            `sso_verification_failed` — the provider's ID token did not verify
            (signature, issuer, audience, expiry or nonce); a failed sign-in,
            not a missing account. `email_not_verified` — the provider did not
            vouch for the email and no account is linked to this identity yet.
            `user_not_found` — no active account for this person and none was
            provisioned. `account_locked` — the account is locked after too many
            failed sign-in attempts; no token is minted. `user_deactivated` —
            the account cannot sign in.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            `user_not_authorized` — the person's membership in this business
            has ended, or the account may not sign in here.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Business or SSO provider not found (`sso_provider_not_found`)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/refresh":
    post:
      tags:
      - Authentication
      summary: Refresh access token
      description: |
        Get a new access token using refresh token. Not the Office add-in's
        refresh token, which is exchanged at `/auth/office_addin/refresh` for
        the add-in's own token.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - refresh_token
              properties:
                refresh_token:
                  type: string
                  description: Valid refresh token
      responses:
        '200':
          description: Token refreshed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                  expires_in:
                    type: integer
                  token_type:
                    type: string
                    example: Bearer
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/logout":
    post:
      tags:
      - Authentication
      summary: Logout and revoke tokens
      description: Revoke current access token and optionally refresh token
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token:
                  type: string
                  description: Refresh token to revoke (optional)
      responses:
        '200':
          description: Logged out successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Logged out successfully
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/webview_token":
    post:
      tags:
      - Authentication
      summary: Mint a one-time hand-off into a web session
      description: |
        A native app (mobile or desktop) turns its API sign-in into a browser
        session: it asks here with its Bearer token, then opens
        `/m/login/native` with the token, which signs the browser in. The
        token is signed, works once and expires after 60 seconds.

        Only the access token a sign-in door gave the person, with its full
        permissions, may ask — the browser session it starts has no second
        factor of its own. Personal, integration and CLI tokens, service
        accounts, impersonation tokens and a browser's own cookie session are
        refused, and so is a locked or deactivated account.

        **Token scopes:** none beyond being the sign-in access token.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The hand-off token.
          content:
            application/json:
              schema:
                type: object
                required:
                - webview_token
                - expires_in
                properties:
                  webview_token:
                    type: string
                    description: Pass it to `/m/login/native` within a minute. It
                      is redeemed once.
                  expires_in:
                    type: integer
                    example: 60
        '401':
          description: "`authentication_required` or `invalid_token` (no valid Bearer
            token), or `webview_token_requires_access_token` (the request carried
            only a browser session cookie)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`webview_token_impersonation_not_allowed`, `webview_token_service_account_not_allowed`,
            `webview_token_requires_sign_in_token` (a personal, integration or CLI
            token, or one with narrowed scopes), `webview_token_account_locked` or
            `webview_token_account_inactive`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/profile":
    get:
      tags:
      - Authentication
      summary: Get enhanced user profile
      description: "Retrieve comprehensive user profile including security info, preferences,
        \npermissions, and feature flags. This endpoint provides more detailed \ninformation
        than the basic /users/me endpoint.\n"
      responses:
        '200':
          description: Profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    "$ref": "#/components/schemas/EnhancedUser"
                  business:
                    "$ref": "#/components/schemas/Business"
                  security:
                    "$ref": "#/components/schemas/SecurityInfo"
                  preferences:
                    type: object
                    description: User preferences and settings
                  permissions:
                    type: array
                    items:
                      type: string
                    description: User permissions
                  feature_flags:
                    type: object
                    description: Available feature flags for user
                  custom_fields:
                    type: array
                    description: The organization's own profile fields on the caller
                      that they may see on themself, as `GET /users/me` lists them.
                      Empty while custom fields are off for the organization.
                    items:
                      "$ref": "#/components/schemas/ProfileCustomField"
                  personnel_file_copy_request:
                    type: object
                    nullable: true
                    description: The caller's waiting request for a copy of their
                      personnel file (POST /profile/actions request_personnel_file_copy),
                      or null. Null under View-as.
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        example: requested
                      requested_at:
                        type: string
                        format: date
                      due_on:
                        type: string
                        format: date
                  personnel_file_copy_sent_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: When HR recorded sending the caller the last copy
                      of their personnel file they asked for (the web and phone "HR
                      sent you a copy on …"), or null. Null under View-as.
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Authentication
      summary: Update user profile
      description: |
        Update user profile information including preferences and notification settings.

        Called with an impersonation token, a request whose `phone` (or an `email` or `userid` value) differs
        from the stored one answers 403 `impersonation_cannot_change_account_security`; sending the stored
        values back unchanged is not refused. When employee self-service profile editing is turned off for the
        organization, a caller below administrator gets 403 `self_service_disabled`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name:
                  type: string
                  example: John
                last_name:
                  type: string
                  example: Doe
                phone:
                  type: string
                  example: "+1234567890"
                time_zone:
                  type: string
                  example: America/New_York
                language:
                  type: string
                  nullable: true
                  description: |
                    The person's own language, one of the organization's languages. Blank or `null` is "Not set"
                    — content follows their place's language (Knowledge Layer P4). The value GET /auth/profile
                    returned for someone who never chose one (`language_set: false`) is their place's language;
                    posting it back changes nothing. Their own stored value is always accepted back.
                  example: fr
                preferences:
                  type: object
                  description: User preferences to update
                notification_preferences:
                  type: object
                  description: Notification preferences to update
      responses:
        '200':
          description: Profile updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Profile updated successfully
                  user:
                    "$ref": "#/components/schemas/EnhancedUser"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/ImpersonationAccountSecurityForbidden"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/auth/change_password":
    post:
      tags:
      - Authentication
      summary: Change user password
      description: |
        Change the current user's password with proper verification.
        When called with an API token (personal or service account) and `revoke_other_sessions: true`, the account's
        other active API tokens are revoked. A caller authenticated by a web session revokes nothing,
        and `revoked_sessions` reports what actually ran. An impersonation token can't change the
        password at all: it gets 403 `impersonation_cannot_change_account_security`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              - new_password
              - new_password_confirmation
              properties:
                current_password:
                  type: string
                  description: Current password for verification
                new_password:
                  type: string
                  description: New password (minimum 6 characters)
                new_password_confirmation:
                  type: string
                  description: Confirmation of new password
                revoke_other_sessions:
                  type: boolean
                  default: false
                  description: Send JSON `true` to revoke the account's other active
                    API tokens. Honoured only for an API token caller (personal or
                    service account).
      responses:
        '200':
          description: Password changed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Password changed successfully
                  revoked_sessions:
                    type: boolean
                    description: True when this call revoked the account's other API
                      tokens (see `revoked_sessions_count`), false when no revocation
                      ran.
                  revoked_sessions_count:
                    type: integer
                    description: Number of other API tokens this call revoked; 0 when
                      none were revoked.
                    example: 2
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/ImpersonationAccountSecurityForbidden"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/auth/password_reset/forgot_password":
    post:
      tags:
      - Authentication
      - Password Reset
      summary: Request password reset
      description: |
        Initiate password reset process by sending a reset link to the user's email.
        Returns a generic success message regardless of whether the email exists (security best practice).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              properties:
                email:
                  type: string
                  format: email
                  description: Email address of the account
                  example: user@example.com
      responses:
        '200':
          description: Password reset email sent (or email doesn't exist - generic
            response for security)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: If an account exists with this email, you will receive
                      password reset instructions shortly.
                  email:
                    type: string
                    example: user@example.com
        '400':
          "$ref": "#/components/responses/BadRequest"
  "/auth/password_reset/validate_token":
    get:
      tags:
      - Authentication
      - Password Reset
      summary: Validate password reset token
      description: |
        Check if a password reset token is valid and not expired.
        Tokens expire after 6 hours.
      security: []
      parameters:
      - name: token
        in: query
        required: true
        schema:
          type: string
        description: Password reset token from email
        example: abc123xyz789
      responses:
        '200':
          description: Token is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: true
                  email:
                    type: string
                    example: user@example.com
                  expires_at:
                    type: string
                    format: date-time
                    example: '2025-10-10T20:00:00Z'
        '401':
          description: Token is invalid or expired
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: invalid_or_expired_token
                  message: Password reset token is invalid or has expired.
  "/auth/password_reset/reset_password":
    post:
      tags:
      - Authentication
      - Password Reset
      summary: Reset password with token
      description: |
        Complete the password reset process using a valid reset token.
        A successful reset revokes every API token the account holds, in every organization
        (sign-in and refresh tokens, personal and CLI tokens), so each device or integration
        must sign in again or be issued a new token.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - token
              - password
              - password_confirmation
              properties:
                token:
                  type: string
                  description: Password reset token from email
                  example: abc123xyz789
                password:
                  type: string
                  format: password
                  description: New password (minimum 6 characters)
                  example: newSecurePassword123
                password_confirmation:
                  type: string
                  format: password
                  description: Confirmation of new password
                  example: newSecurePassword123
                revoke_all_sessions:
                  type: boolean
                  deprecated: true
                  description: Ignored. A successful reset always revokes every API
                    token the account holds.
      responses:
        '200':
          description: Password reset successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Your password has been reset successfully.
                  email:
                    type: string
                    example: user@example.com
                  revoked_all_sessions:
                    type: boolean
                    description: True on success. Every API token the account held
                      has been revoked.
                    example: true
        '400':
          description: Invalid request (passwords don't match, too weak, etc.)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Token is invalid or expired
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/auth/connections":
    get:
      tags:
      - Authentication
      summary: List my connected assistants
      description: |
        The assistants that can act for the caller — MCP clients they signed in
        to (one row per client), their personal API tokens and their Office
        add-in sign-ins (one row per Word or Excel pane that signed in) — with
        what each may do in plain words, and the caller's latest assistant
        activity.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller's connections
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Connection"
                  recent_activity:
                    type: array
                    description: The caller's latest assistant tool calls, newest
                      first (at most 10).
                    items:
                      type: object
                      properties:
                        at:
                          type: string
                          format: date-time
                        client:
                          type: string
                          nullable: true
                          example: Claude
                        tool:
                          type: string
                          example: list_workspace_messages
                        outcome:
                          type: string
                          enum:
                          - started
                          - completed
                          - failed
                          - preview
                          - replayed
                          - denied
                        success:
                          type: boolean
                  meta:
                    type: object
                    properties:
                      connections_enabled:
                        type: boolean
                        description: False when the organization has turned assistant
                          connections off; nothing listed can act until it is turned
                          back on.
        '401':
          description: Authentication required
  "/auth/connections/{kind}/{id}":
    delete:
      tags:
      - Authentication
      summary: Revoke one of my connected assistants
      description: |
        Disconnects an MCP client (every grant the caller gave it in this
        organization, with its pending authorization codes), revokes a
        personal API token, or signs out an Office add-in sign-in (its refresh
        token and every live add-in access token the caller holds here — the
        add-in's own sign-out). It stops working at once; a client can be
        connected again by signing in from it. Needs a token with the
        `write:connections` scope — the app's own sign-in and the CLI carry
        it — so a narrow token cannot revoke the caller's other credentials.
      security:
      - BearerAuth: []
      parameters:
      - name: kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - oauth
          - token
          - office_addin
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The client's or token's id, as listed.
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Claude is disconnected
                  revoked:
                    type: object
                    properties:
                      name:
                        type: string
                      kind:
                        type: string
                        enum:
                        - oauth
                        - token
                        - office_addin
                      revoked:
                        type: integer
                        description: Grants or tokens revoked.
        '401':
          description: Authentication required
        '403':
          description: |
            The token has no `write:connections` scope (`insufficient_permissions`),
            or it is an impersonation session, which cannot revoke
            (`impersonation_cannot_change_account_security`).
        '404':
          description: Not one of the caller's connections
  "/auth/office_addin/device":
    post:
      tags:
      - Authentication
      summary: Start the Office add-in's sign-in
      description: |
        Issues a device code (kept by the add-in) and a short user code the
        person checks on the sign-in page, bound to the workspace whose
        address the request was sent to. Open `verification_url_complete` in
        the person's browser (or show `verification_url` and the code to type
        on any device), then poll `/auth/office_addin/token` every `interval`
        seconds until `expires_in` runs out.
      security: []
      responses:
        '200':
          description: A code to allow
          content:
            application/json:
              schema:
                type: object
                properties:
                  device_code:
                    type: string
                    description: Secret. Sent only in the body of `/auth/office_addin/token`.
                  user_code:
                    type: string
                    example: ABCD-EFGH
                  verification_url:
                    type: string
                    example: https://acme.workforce.mangoapps.com/auth/office-addin
                  verification_url_complete:
                    type: string
                    example: https://acme.workforce.mangoapps.com/auth/office-addin?code=ABCD-EFGH
                  expires_in:
                    type: integer
                    example: 600
                  interval:
                    type: integer
                    example: 5
        '422':
          description: "`workspace_required` — the address names no MangoApps workspace."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Too many sign-ins started from this address.
  "/auth/office_addin/token":
    post:
      tags:
      - Authentication
      summary: Collect the Office add-in's sign-in
      description: |
        Poll with the device code until the person allows or denies the
        sign-in. Once allowed, the first poll returns a one-hour access token
        and a 30-day refresh token; every later poll of the same code answers
        410. The tokens are minted then — never stored in plaintext waiting
        for a poll — and the response is not cacheable.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - device_code
              properties:
                device_code:
                  type: string
      responses:
        '200':
          description: Signed in
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: approved
                  access_token:
                    type: string
                  refresh_token:
                    type: string
                  token_type:
                    type: string
                    example: Bearer
                  expires_in:
                    type: integer
                    example: 3600
                  scopes:
                    type: array
                    items:
                      type: string
                    example:
                    - read:contracts
                    - write:contracts
                    - destructive:contracts
                    - read:drive
                    - write:drive
                  user:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      email:
                        type: string
                        nullable: true
                  business:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      subdomain:
                        type: string
                        nullable: true
        '202':
          description: Not allowed yet — poll again after `interval` seconds.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: authorization_pending
        '400':
          description: "`invalid_request` — no device_code."
        '403':
          description: |
            `status: denied` — the person chose Deny, or their account may no
            longer sign in (`error.code: access_denied`).
        '404':
          description: "`invalid_device_code` — not a code this door issued."
        '410':
          description: "`status: expired` — the code expired or was already collected
            (`error.code: expired_token`)."
        '429':
          description: Polled too often from this address; poll more slowly.
  "/auth/office_addin/refresh":
    post:
      tags:
      - Authentication
      summary: Refresh the Office add-in's access token
      description: |
        A new one-hour access token for the add-in's live refresh token, while
        its person may still sign in. Only the add-in's refresh token is
        accepted here, and the app sign-in's `/auth/refresh` does not accept
        it.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - refresh_token
              properties:
                refresh_token:
                  type: string
      responses:
        '200':
          description: A new access token
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                  token_type:
                    type: string
                    example: Bearer
                  expires_in:
                    type: integer
                    example: 3600
        '401':
          description: "`invalid_refresh_token` — the sign-in has ended; sign in again."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/office_addin/sign_out":
    post:
      tags:
      - Authentication
      summary: Sign the Office add-in out
      description: |
        Revokes the add-in's own access token (the Bearer header) and refresh
        token (the body) — either is enough, so a pane whose access token ran
        out can still sign out. Any other token is left alone: a token pasted
        into the add-in is never revoked here.
      security: []
      parameters:
      - name: Authorization
        in: header
        required: false
        schema:
          type: string
          example: Bearer <the add-in access token>
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token:
                  type: string
      responses:
        '200':
          description: Signed out
          content:
            application/json:
              schema:
                type: object
                properties:
                  signed_out:
                    type: boolean
                    example: true
        '401':
          description: "`invalid_token` — neither credential is an Office add-in sign-in."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/sessions":
    get:
      tags:
      - Authentication
      summary: List active sessions
      description: |
        Retrieve a list of all active sessions (API tokens) for the current user.
        Includes session details like device info, location, and last activity.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Sessions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Session"
                  meta:
                    type: object
                    properties:
                      current_session_id:
                        type: integer
                        description: ID of the current session
                      total_active_sessions:
                        type: integer
                        description: Total number of active sessions
                  pagination:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/sessions/current":
    get:
      tags:
      - Authentication
      summary: Get current session details
      description: Retrieve detailed information about the current active session
      responses:
        '200':
          description: Current session retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  session:
                    "$ref": "#/components/schemas/DetailedSession"
                  is_current_session:
                    type: boolean
                    example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/sessions/revoke_all":
    post:
      tags:
      - Authentication
      summary: Revoke all other sessions
      description: |
        Revoke all active sessions except the current one. Useful for security
        when a user suspects their account may be compromised. Needs a token
        with the `write:connections` scope.
      responses:
        '200':
          description: Other sessions revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Successfully revoked 3 other sessions
                  revoked_count:
                    type: integer
                    description: Number of sessions revoked
                  current_session_preserved:
                    type: boolean
                    example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
  "/auth/sessions/{id}":
    get:
      tags:
      - Authentication
      summary: Get session details
      description: Retrieve detailed information about a specific session
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Session ID
      responses:
        '200':
          description: Session details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  session:
                    "$ref": "#/components/schemas/DetailedSession"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
    delete:
      tags:
      - Authentication
      summary: Revoke specific session
      description: |
        Revoke a specific session by ID. Cannot revoke the current session -
        use the logout endpoint instead. Needs a token with the
        `write:connections` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Session ID to revoke
      responses:
        '200':
          description: Session revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Session revoked successfully
                  revoked_session:
                    "$ref": "#/components/schemas/Session"
        '400':
          description: Cannot revoke current session
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/2fa/status":
    get:
      tags:
      - Two-Factor Authentication
      summary: Get 2FA status
      description: Retrieve the current two-factor authentication status and configuration
      responses:
        '200':
          description: 2FA status retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  two_factor_enabled:
                    type: boolean
                    description: Whether 2FA is currently enabled
                  two_factor_status:
                    type: string
                    enum:
                    - disabled
                    - pending_setup
                    - pending_confirmation
                    - enabled
                    description: Current 2FA setup status
                  setup_required:
                    type: boolean
                    description: Whether 2FA setup is required by organization policy
                  backup_codes_count:
                    type: integer
                    description: Number of remaining backup codes
                  setup_at:
                    type: string
                    format: date-time
                    description: When 2FA setup was initiated
                  confirmed_at:
                    type: string
                    format: date-time
                    description: When 2FA was confirmed and enabled
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/2fa/setup":
    post:
      tags:
      - Two-Factor Authentication
      summary: Setup 2FA
      description: |
        Initiate two-factor authentication setup. Returns QR code URI and backup codes.
        Requires current password for verification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: 2FA setup initiated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Two-factor authentication setup initiated
                  qr_code_uri:
                    type: string
                    description: QR code URI for authenticator apps
                  secret:
                    type: string
                    description: Secret key for manual entry
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: Backup codes for account recovery
                  next_step:
                    type: string
                    description: Instructions for next step
        '400':
          description: 2FA already enabled or invalid request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
  "/auth/2fa/verify":
    post:
      tags:
      - Two-Factor Authentication
      summary: Verify and enable 2FA
      description: |
        Complete 2FA setup by verifying a code from the authenticator app.
        This enables 2FA for the user account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - code
              properties:
                code:
                  type: string
                  description: 6-digit verification code from authenticator app
                  example: '123456'
      responses:
        '200':
          description: 2FA enabled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Two-factor authentication enabled successfully
                  two_factor_enabled:
                    type: boolean
                    example: true
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: Backup codes for account recovery
        '400':
          description: Invalid code or 2FA not setup
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
  "/auth/2fa/disable":
    post:
      tags:
      - Two-Factor Authentication
      summary: Disable 2FA
      description: |
        Disable two-factor authentication for the user account.
        Requires current password and either a 2FA code or backup code.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
                code:
                  type: string
                  description: 6-digit verification code from authenticator app
                  example: '123456'
                backup_code:
                  type: string
                  description: Backup code (alternative to verification code)
                  example: a1b2c3d4
      responses:
        '200':
          description: 2FA disabled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Two-factor authentication disabled successfully
                  two_factor_enabled:
                    type: boolean
                    example: false
        '400':
          description: 2FA not enabled or invalid request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
  "/auth/2fa/backup_codes":
    get:
      tags:
      - Two-Factor Authentication
      summary: Get backup codes (retired verb)
      deprecated: true
      description: |
        Retired: a GET carried the account password in the URL, where proxy and load-balancer
        logs, browser history and Referer headers keep it. Answers 405 naming the POST below
        (or 400 `password_in_url` when the URL carries `current_password`).
      responses:
        '400':
          description: "`password_in_url` — the URL carried `current_password`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '405':
          description: "`method_not_allowed` — read backup codes with POST"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Two-Factor Authentication
      summary: Get backup codes
      description: |
        Retrieve current backup codes for 2FA recovery. Requires the current password in the
        request body (a `current_password` in the URL is refused with `password_in_url`) and a
        token carrying `write:connections`. Each read is recorded on the person's activity log.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Backup codes retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: Current backup codes
                  codes_count:
                    type: integer
                    description: Number of backup codes
                  warning:
                    type: string
                    description: Security warning about backup codes
        '400':
          description: 2FA not enabled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
  "/auth/2fa/regenerate_backup_codes":
    post:
      tags:
      - Two-Factor Authentication
      summary: Regenerate backup codes
      description: |
        Generate new backup codes, invalidating all previous codes.
        Requires current password for security.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Backup codes regenerated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Backup codes regenerated successfully
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: New backup codes
                  warning:
                    type: string
                    description: Warning about old codes being invalidated
        '400':
          description: 2FA not enabled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
  "/auth/passwordless/request":
    post:
      tags:
      - Passwordless Authentication
      summary: Request passwordless authentication (unavailable)
      deprecated: true
      description: |
        Not available. This endpoint used to accept a request for a magic link, email code or
        SMS code but never delivered one, so no request could end in a sign-in. It now issues
        nothing and answers every request, whatever its body, with the same
        `403 feature_disabled`. `GET /auth/login_config` reports `login_methods.passwordless.enabled: false`.
        Sign in with `POST /auth/login` or single sign-on.

        `POST /auth/passwordless/verify` is unaffected: it still redeems sign-in links issued elsewhere.
      security: []
      responses:
        '403':
          description: Passwordless sign-in is not available. The same body is returned
            for every request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: feature_disabled
                  message: Sign-in links and codes are not available through the app.
                    Sign in with your password or single sign-on.
        '429':
          description: Too many requests for this address or from this IP
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/passwordless/verify":
    post:
      tags:
      - Passwordless Authentication
      summary: Verify passwordless authentication token
      description: |
        Verify a passwordless authentication token and receive API access tokens.
        Accepts magic-link tokens only, and each token signs in once. Six-digit
        email and SMS codes belong to the web and phone sign-in doors that sent
        them and are answered here as an unknown token (401 invalid_token).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - token
              properties:
                token:
                  type: string
                  description: Authentication token from email/SMS
                  example: abc123def456
      responses:
        '200':
          description: Authentication successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token:
                    type: string
                    description: API access token
                  refresh_token:
                    type: string
                    description: Token refresh token
                  expires_in:
                    type: integer
                    description: Token expiration in seconds
                    example: 3600
                  token_type:
                    type: string
                    example: Bearer
                  authentication_method:
                    type: string
                    example: passwordless
                  passwordless_type:
                    type: string
                    example: magic_link
                  user:
                    "$ref": "#/components/schemas/User"
                  business:
                    "$ref": "#/components/schemas/Business"
        '401':
          description: Invalid or expired token
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/passwordless/status":
    get:
      tags:
      - Passwordless Authentication
      summary: Get passwordless authentication status
      description: |
        Get information about recent passwordless authentication attempts
        and system configuration.
      responses:
        '200':
          description: Status retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  passwordless_enabled:
                    type: boolean
                    description: Whether `POST /auth/passwordless/request` issues
                      anything. Always false.
                    example: false
                  recent_tokens:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PasswordlessToken"
                  supported_types:
                    type: array
                    description: Types `POST /auth/passwordless/request` issues. Always
                      empty.
                    items:
                      type: string
                    example: []
                  rate_limit_info:
                    type: object
                    properties:
                      max_requests_per_5_minutes:
                        type: integer
                        example: 3
                      current_period_requests:
                        type: integer
                        example: 1
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/tokens":
    get:
      tags:
      - API Token Management
      summary: List personal API tokens
      description: |
        Retrieve a list of personal API tokens for the current user.
        Excludes session tokens and service account tokens.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Tokens retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PersonalApiToken"
                  meta:
                    type: object
                    properties:
                      total_tokens:
                        type: integer
                        description: Number of your personal API tokens, the same
                          set `data` lists (no service accounts, no tokens the app
                          mints at sign-in); revoked and expired tokens included
                      active_tokens:
                        type: integer
                        description: How many of those personal tokens are active
                          (not revoked, not expired)
                      available_scopes:
                        type: array
                        items:
                          type: string
                        description: Available scopes for personal tokens
                  pagination:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    post:
      tags:
      - API Token Management
      summary: Create personal API token
      description: |
        Create a new personal API token with specified scopes and expiration.
        Requires current password for security verification, and a token with
        the `write:connections` scope (the app's own sign-in and the CLI carry
        it; a token you make carries it only when you grant it).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                name:
                  type: string
                  description: Token name/description
                  example: My Automation Token
                  maxLength: 100
                current_password:
                  type: string
                  description: Current password for verification
                scopes:
                  type: array
                  items:
                    type: string
                  description: Requested scopes (filtered to employee-safe scopes)
                  example:
                  - read:own_profile
                  - read:own_shifts
                  - write:own_attendance
                expires_at:
                  type: string
                  format: date-time
                  description: Optional expiration date
                  example: '2024-12-31T23:59:59Z'
      responses:
        '201':
          description: Token created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: API token created successfully
                  token:
                    "$ref": "#/components/schemas/DetailedPersonalApiToken"
                  warning:
                    type: string
                    example: Store this token securely. You will not be able to see
                      it again.
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
        '422':
          description: Token limit exceeded or validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/tokens/scopes":
    get:
      tags:
      - API Token Management
      summary: Get available token scopes
      description: |
        Retrieve list of available scopes for personal API tokens
        with descriptions and categories.
      responses:
        '200':
          description: Scopes retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  available_scopes:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          example: read:own_profile
                        description:
                          type: string
                          example: View your profile information
                        category:
                          type: string
                          example: profile
                  scope_categories:
                    type: object
                    additionalProperties:
                      type: string
                    example:
                      profile: User profile and preferences
                      shifts: Shift and schedule information
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/auth/tokens/{id}":
    get:
      tags:
      - API Token Management
      summary: Get token details
      description: Retrieve detailed information about a specific personal API token
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID
      responses:
        '200':
          description: Token details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    "$ref": "#/components/schemas/DetailedPersonalApiToken"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
    delete:
      tags:
      - API Token Management
      summary: Delete personal API token
      description: |
        Delete (revoke) a personal API token. Requires current password for security, and a
        token with the `write:connections` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID to delete
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Token deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: API token revoked.
                  deleted_token:
                    "$ref": "#/components/schemas/PersonalApiToken"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/tokens/{id}/rotate":
    patch:
      tags:
      - API Token Management
      summary: Rotate API token
      description: |
        Generate a new token value for an existing API token while preserving
        all other settings. Requires current password for security, and a
        token with the `write:connections` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID to rotate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - current_password
              properties:
                current_password:
                  type: string
                  description: Current password for verification
      responses:
        '200':
          description: Token rotated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: API token rotated successfully
                  token:
                    "$ref": "#/components/schemas/DetailedPersonalApiToken"
                  old_token:
                    type: object
                    description: Information about the old token (without token value)
                  warning:
                    type: string
                    example: Update your applications with the new token. The old
                      token is no longer valid.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CredentialsWriteForbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/tokens/{id}/usage":
    get:
      tags:
      - API Token Management
      summary: Get token usage statistics
      description: |
        Retrieve usage statistics and analytics for a personal API token.
        Note: Detailed usage tracking is being implemented.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Token ID
      responses:
        '200':
          description: Usage statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  usage:
                    "$ref": "#/components/schemas/TokenUsage"
                  note:
                    type: string
                    example: Usage tracking is being implemented and will provide
                      detailed analytics soon.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/auth/impersonate":
    post:
      tags:
      - Impersonation
      summary: Create impersonation token
      description: |
        Create a short-lived impersonation token to act on behalf of a specific user.
        Requires a service account with `impersonate` or `admin` scope.

        Impersonation tokens allow service accounts to access user-specific endpoints
        (e.g., `/api/v1/users/me`, `/api/v1/leave_balances`) as if authenticated as that user.

        All access using impersonation tokens is automatically logged for audit purposes.
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                  description: ID of the user to impersonate
                  example: 123
      responses:
        '201':
          description: Impersonation token created successfully. The token value is
            returned only in this response; it is stored as a digest and cannot be
            shown again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: Impersonation token (use as a Bearer token in the
                      Authorization header)
                    example: a1b2c3d4e5f6...
                  expires_at:
                    type: string
                    format: date-time
                    description: Token expiration time
                    example: '2024-01-15T14:30:00Z'
                  impersonated_user:
                    type: object
                    properties:
                      id:
                        type: integer
                      email:
                        type: string
                      name:
                        type: string
                  instructions:
                    type: object
                    properties:
                      usage:
                        type: string
                      example:
                        type: string
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The caller is not a service account (`service_account_required`), or the service account lacks the `impersonate` or `admin` scope (`insufficient_scope`).

            `impersonation_target_not_allowed` (only while impersonation target checks are enabled for the installation): the person may not be impersonated through this token. The `message` gives the reason:
            - "This person is a Super Admin in another organization, so only a Super Admin there can view the system as them."
            - "This person also belongs to another organization you don't administer, so only someone who administers every other organization they belong to can view the system as them." ("you" is the token's owner.)
            - "Only a token whose owner is a Super Admin in this organization can impersonate a Super Admin. Re-issue the token under a person who is a Super Admin here; a service account can't be one."

            While the checks are enabled they also run on every request of an open impersonation session, and a token whose person would now be refused gets `401`. While they are disabled, none of these refusals applies.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: insufficient_scope
                  message: Service account requires 'impersonate' or 'admin' scope
        '404':
          description: User not found in current business
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: user_not_found
                  message: User not found in current business
    get:
      tags:
      - Impersonation
      summary: List active impersonation tokens
      description: |
        Returns the active impersonation tokens this service account created in the current
        business, newest first, at most 100. Requires a service account token with the
        `impersonate` or `admin` scope; any other caller, including an impersonation token,
        is refused with 403.
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      responses:
        '200':
          description: Impersonation tokens retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          description: Impersonation token record ID. The token value
                            itself is never listed; it is returned once, when the
                            token is created.
                        impersonated_user:
                          type: object
                          properties:
                            id:
                              type: integer
                            email:
                              type: string
                            name:
                              type: string
                        created_at:
                          type: string
                          format: date-time
                        expires_at:
                          type: string
                          format: date-time
                          description: Token expiration time
                        revoked:
                          type: boolean
                  total_count:
                    type: integer
                    description: Number of tokens returned (at most 100).
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is not a service account (`service_account_required`),
            or the service account lacks the `impersonate` or `admin` scope (`insufficient_scope`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/auth/impersonate/{token}":
    delete:
      tags:
      - Impersonation
      summary: Revoke impersonation token
      description: |
        Revoke an impersonation token. Only the service account that created the token
        can revoke it.
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
        description: The impersonation token to revoke
      responses:
        '200':
          description: Token revoked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: object
                    properties:
                      message:
                        type: string
                        example: Impersonation token revoked
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Access denied
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Token not found or not owned by this service account
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/home":
    get:
      tags:
      - Home
      summary: Get home screen data
      description: "Returns comprehensive home screen data including dashboard widgets,
        \nuser profile, business context, notifications, and quick stats.\n\nThis
        endpoint provides all data needed to render a complete mobile \nhome screen
        in a single API call.\n\n### Widget System\nThe dashboard includes configurable
        widgets based on:\n- User role (employee, manager, admin, super_admin)\n-
        Enabled business features\n- Active marketplace apps\n\n### Performance Metrics\nIncludes
        user performance data such as:\n- Weekly hours worked\n- Attendance rate and
        punctuality\n- Completed shifts count\n\n### Real-time Data\n- Current attendance
        status\n- Upcoming shifts\n- Unread notifications\n- Quick action shortcuts\n\n**Required
        Scopes:** `read:widgets` (#7183, owner rule 2026-10-06\n\"every api/v1 door
        asks a scope\") — the same aggregated, person-scoped\nhome payload as `GET
        /api/v1/widgets`, which asks it. A token without it\ngets `403 insufficient_permissions`
        (\"Required scope: read:widgets\").\nThe Inbox nodes inside (`waiting_on_you`,
        `notifications`, a manager's\n`quick_stats.pending_approvals`) still ask `read:notifications`
        on top,\nand are `null` without it. The sign-in tokens hold both; a browser\nsession
        is asked no scope.\n"
      parameters:
      - name: waiting_on_you_older
        in: query
        required: false
        schema:
          type: boolean
        description: 'Opt in to `waiting_on_you.older` — what is still waiting on
          the caller but was asked more than 90 days ago (the web Inbox''s collapsed
          "Older" group). Absent unless `1`/`true`; it costs a second pass over the
          approvals queues.

          '
      - name: surface
        in: query
        required: false
        schema:
          type: string
          enum:
          - desktop
        description: "`desktop` answers one section of the desktop app's Home instead
          of the payload below; `section` says which.\n"
      - name: section
        in: query
        required: false
        schema:
          type: string
          enum:
          - manifest
          - attention
          - focus
          - schedule
          - today
          - you
          - review
          - week
        description: 'With `surface=desktop`. `attention` is Waiting on you with what
          each row asks (`read:notifications` on top). `focus` is the desktop''s "Do
          this first": up to three of the person''s ledger rows, most urgent first,
          leaving out what Waiting on you lists. Each row''s `can_snooze` and `can_dismiss`
          say what this token may do. `schedule` is the desktop Today''s schedule:
          today''s calendar in the person''s zone with each meeting''s join link,
          their answer and its AI Notepad note, the first invites waiting for an answer,
          and tomorrow''s first item once the day is clear, and for a manager `team_out`:
          who on their team is out today, with the reason only where the viewer may
          see it. It asks the Calendar''s `read:calendar_sync` and leaves out acknowledgment
          deadlines, which Waiting on you lists. `you` is the right column: the person''s
          time off (balances, a request waiting, the next approved leave and the leave
          types they may request), recognition received this week, today''s birthdays
          and work anniversaries in their department, and for a manager one report
          to recognize. `review` is To review: up to three items the person owns whose
          "Still accurate?" review is overdue or due soon, soonest first, as Content
          I own lists them, with `total` and each item''s `can_mark`. `week` is the
          weekly wrap: the signed-in person''s own week in at most five lines, counted
          by rules, from Friday 12:00 to Monday 12:00 in their zone. It answers `state`
          (`shown`, `outside_window`, `off_for_company`, `off_for_you`, or `unavailable`
          for an impersonated session, which is never answered any lines), `company_enabled`
          and `personal_enabled` (always present), `week_of` (the Monday of the week
          counted) and `shows_until` (the next Monday 12:00), both null unless shown,
          and `lines`: the first five, in this order, of `recognized_by` {count, names},
          `messages` {sent, received, conversations, top: [{type: person|room, name}]},
          `post_acknowledged` {room_name, acknowledged, of}, `clip_watched` {title,
          views, viewers}, `meetings` {meetings, noted, action_items}, `first_conversations`
          {count}, `mentions` {count} and `reactions` {received}, each sent only when
          it has something to say. The week counted is Monday 00:00 to Friday 12:00,
          worked out once and kept until the wrap closes; it counts facts, never scores
          or comparisons. There is no parameter to ask for anyone else''s week.

          '
      - name: calendars
        in: query
        required: false
        schema:
          type: string
          enum:
          - connected
        description: 'With `section=schedule`: `connected` answers the connected Google
          / Outlook calendars alone. Those may be read live from the provider, so
          a client asks for them apart and the rest never waits on them.

          '
      responses:
        '200':
          description: Home screen data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    allOf:
                    - "$ref": "#/components/schemas/User"
                    - type: object
                      properties:
                        language:
                          type: string
                          description: |
                            The language content reaches the caller in — own
                            setting, else their place's, else the company
                            default. Never null (Knowledge Layer P4).
                          example: en
                        language_set:
                          type: boolean
                          description: Whether the caller chose a language themselves
                            (`false` is "Not set").
                        onboarding_completed:
                          type: boolean
                          description: Whether user has completed onboarding
                        onboarding_progress:
                          type: number
                          minimum: 0
                          maximum: 100
                          description: Onboarding completion percentage
                        preferences:
                          type: object
                          description: User preferences and settings
                  business:
                    allOf:
                    - "$ref": "#/components/schemas/Business"
                    - type: object
                      properties:
                        settings:
                          type: object
                          properties:
                            shifts_scheduling_enabled:
                              type: boolean
                            time_attendance_enabled:
                              type: boolean
                            leave_management_enabled:
                              type: boolean
                            timesheets_enabled:
                              type: boolean
                            skills_certifications_enabled:
                              type: boolean
                            enabled_marketplace_apps:
                              type: array
                              items:
                                type: string
                              description: List of enabled marketplace app slugs
                            time_clock_kiosk_enabled:
                              type: boolean
                              description: 'Tenant runs a shared Time Clock Kiosk,
                                so punching moved off personal devices onto the tablet
                                at the site. Clients must stop offering Clock In /
                                Clock Out and show the kiosk card from current_status.kiosk
                                instead.

                                '
                            business_hours:
                              type: object
                              additionalProperties:
                                type: object
                                properties:
                                  open:
                                    type: string
                                    format: time
                                  close:
                                    type: string
                                    format: time
                                  closed:
                                    type: boolean
                            week_start_day:
                              type: string
                              enum:
                              - sunday
                              - monday
                              - tuesday
                              - wednesday
                              - thursday
                              - friday
                              - saturday
                              default: monday
                  dashboard:
                    type: object
                    properties:
                      widgets:
                        type: array
                        items:
                          type: object
                          properties:
                            name:
                              type: string
                              description: Widget identifier
                            title:
                              type: string
                              description: Display title
                            description:
                              type: string
                              description: Widget description
                            category:
                              type: string
                              enum:
                              - platform
                              - core_app
                              - marketplace_app
                            icon:
                              type: string
                              description: FontAwesome icon class
                            size:
                              type: string
                              description: Bootstrap grid size class
                            type:
                              type: string
                              description: Widget category type
                            priority:
                              type: integer
                              description: Display priority (lower = higher priority)
                            data:
                              type: object
                              description: |
                                Widget-specific data content following the Simple Widget Specification. Contains both structured data and markdown content for flexible mobile rendering.

                                **Core Structure**:
                                - Original structured data (actions, shifts, notifications, etc.)
                                - `markdown_summary`: Rich markdown content for mobile rendering
                                - `display_hint`: Layout hint (grid, list, feed, card)

                                **Platform Widgets**:
                                - `quick_actions`: { actions: [{ title, path, icon, variant }], markdown_summary, display_hint: 'grid' }
                                - `notifications`: { recent: [], unread_count: 0, markdown_summary, display_hint: 'feed' }
                                - `user_profile_summary`: { greeting, weekly_hours, current_status, markdown_summary, display_hint: 'card' }

                                **Core App Widgets**:
                                - `upcoming_shifts`: { shifts: [], user_role, show_team_view, markdown_summary, display_hint: 'list' }
                                - `quick_attendance`: { current_status: { status, shift: { id, date, start_time, end_time, location, duration, is_ad_hoc }, clocked_in_at, duration, can_clock_in }, today_hours, can_clock_in, markdown_summary, display_hint: 'card' }
                                - `leave_requests`: { leave_requests: [{ id, start_date (YYYY-MM-DD), end_date (YYYY-MM-DD), formatted_dates, duration_days, status_badge: { class, text }, leave_type: { name, color }, reason, created_at, url }], leave_balance: [], quick_actions: [], user_role, show_manager_view, markdown_summary, display_hint: 'list' }

                                **Marketplace App Widgets**:
                                - `epms_*`: Performance management data with markdown_summary and display_hint
                                - `training_connect_*`: Training data with markdown_summary and display_hint
                                - `okr_progress`: OKR data with markdown_summary and display_hint

                                This hybrid approach provides both structured data for custom rendering and markdown content for quick implementation.
                              additionalProperties: true
                              properties:
                                markdown_summary:
                                  type: string
                                  description: Rich markdown content for mobile rendering.
                                    Contains formatted text with emojis, links, and
                                    styling cues.
                                  example: "## Quick Actions\n\n\U0001F535 **[Clock
                                    In](/attendance/clock_in)**\n\U0001F4C5 **[My
                                    Schedule](/shifts/my_shifts)**\n"
                                display_hint:
                                  type: string
                                  enum:
                                  - grid
                                  - list
                                  - feed
                                  - card
                                  description: |
                                    Layout hint for mobile apps:
                                    - grid: 2-3 column layout for actions/buttons
                                    - list: Single column with dividers for items
                                    - feed: Card-based with timestamps for activity
                                    - card: Full-width summary display
                              example:
                                actions:
                                - title: Clock In
                                  path: "/attendance/clock_in"
                                  icon: fas fa-play
                                  variant: primary
                                - title: My Schedule
                                  path: "/shifts/my_shifts"
                                  icon: fas fa-calendar
                                  variant: outline-info
                            last_updated:
                              type: string
                              format: date-time
                            refresh_interval:
                              type: integer
                              description: Recommended refresh interval in seconds
                            data_schema:
                              type: object
                              description: |
                                JSON Schema describing the expected structure of the 'data' field.
                                Helps mobile developers understand widget data format and handle new widgets gracefully.
                              additionalProperties: true
                            version:
                              type: string
                              description: |
                                Widget version for mobile app compatibility.
                                Increment when widget data structure changes significantly.
                              example: '1.2'
                      widget_count:
                        type: integer
                        description: Total number of active widgets
                      performance_metrics:
                        type: object
                        properties:
                          current_week:
                            type: object
                            properties:
                              hours_worked:
                                type: number
                                format: float
                              shifts_completed:
                                type: integer
                              on_time_rate:
                                type: number
                                format: float
                                minimum: 0
                                maximum: 100
                              attendance_rate:
                                type: number
                                format: float
                                minimum: 0
                                maximum: 100
                          current_month:
                            type: object
                            properties:
                              total_shifts:
                                type: integer
                              hours_worked:
                                type: number
                                format: float
                              performance_score:
                                type: number
                                format: float
                                minimum: 0
                                maximum: 100
                      role_context:
                        type: object
                        properties:
                          current_role:
                            type: string
                            enum:
                            - employee
                            - manager
                            - admin
                            - super_admin
                          permissions:
                            type: array
                            items:
                              type: string
                            description: User's permissions in current business
                          available_actions:
                            type: array
                            items:
                              type: object
                              properties:
                                name:
                                  type: string
                                label:
                                  type: string
                                icon:
                                  type: string
                  attendance_policy:
                    type: object
                    nullable: true
                    description: 'The tenant''s clock-in policy, resolved for the
                      calling user, so a client can render the clock-in gate and know
                      which verifications to gather without re-implementing the rules.
                      `null` when Time & Attendance is disabled for the business or
                      the business has no security setting yet. `settings` mirrors
                      every punch-behaviour field on /admin/attendance_security/settings,
                      using the same names. Kiosk PIN derivation (`default_pin_rule`,
                      `default_pin_custom_value`) is deliberately never returned;
                      the caller''s own PIN-free kiosk credentials ship in `current_status.kiosk`
                      instead.

                      '
                    properties:
                      punches_paused:
                        type: boolean
                        description: 'True while the tenant''s maintenance refuses
                          punches for this caller (a read-only freeze, or a window
                          that blocks every worker door): draw Clock In / Clock Out,
                          breaks and undo disabled instead of offering a punch the
                          server answers with a 503.

                          '
                      paused_until:
                        type: string
                        format: date-time
                        nullable: true
                        description: When the pause is expected to end (ISO 8601);
                          null when it lifts only when an administrator lifts it,
                          or when nothing is paused.
                      clock_in_gate:
                        type: object
                        properties:
                          active:
                            type: boolean
                            description: 'The decision, and the only field a client
                              should branch on. Already folds in scope, admin exemption,
                              approved leave and "already clocked in". Comes from
                              the same resolver the web gate uses, so the two cannot
                              drift.

                              '
                          mode:
                            type: string
                            nullable: true
                            enum:
                            - block
                            - banner
                            description: 'How to present the gate — `block` redirects
                              until clocked in, `banner` reminds without blocking.
                              `null` when the gate is disabled for the tenant.

                              '
                          enabled:
                            type: boolean
                            description: 'Raw tenant setting (is the gate switched
                              on at all). Informational — re-deriving the gate from
                              this will drift from `active`.

                              '
                          scope:
                            type: string
                            enum:
                            - punch_eligible
                            - all_users
                            description: Raw tenant setting for who the gate applies
                              to. Informational.
                      settings:
                        type: object
                        description: Straight projection of the tenant's attendance
                          security settings.
                        properties:
                          allow_remote_clock_in:
                            type: boolean
                            description: Offer a "Working remotely" option in the
                              location picker.
                          allow_offsite_clock_in:
                            type: boolean
                            description: Offer an "Off-site / traveling" option with
                              an optional note.
                          require_shift_for_clock_in:
                            type: boolean
                            description: 'A punch is only accepted against a scheduled
                              shift. When false, an ad-hoc shift is created for an
                              unscheduled clock-in.

                              '
                          require_location_verification:
                            type: boolean
                            description: 'Master switch for geographic verification.
                              Each location must also enable geofencing for enforcement
                              to take effect.

                              '
                          require_device_verification:
                            type: boolean
                          require_photo_verification:
                            type: boolean
                            description: Employee must capture a photo when clocking
                              in or out.
                          enforce_ip_validation_for_clock_actions:
                            type: boolean
                            description: 'Master switch for the IP check. When false,
                              no IP validation happens at all regardless of `ip_validation_mode`.

                              '
                          ip_validation_mode:
                            type: string
                            enum:
                            - passive
                            - warning
                            - strict
                            description: 'Only meaningful when `enforce_ip_validation_for_clock_actions`
                              is true. `passive` records only, `warning` flags for
                              review, `strict` refuses the punch from an unapproved
                              IP.

                              '
                          enforce_time_window_restrictions:
                            type: boolean
                            description: 'Apply the three buffers below. Note the
                              window check is skipped entirely for ad-hoc shifts even
                              when this is true, so do not present a window on a punch
                              against one.

                              '
                          early_clock_in_buffer_minutes:
                            type: integer
                            description: 'Minutes before scheduled shift start that
                              clock-in is allowed. `0` requires the exact start time.

                              '
                          late_clock_in_buffer_minutes:
                            type: integer
                            description: 'Minutes after shift start before a clock-in
                              is flagged as outside the window. `0` requires the exact
                              start time.

                              '
                          late_clock_out_buffer_minutes:
                            type: integer
                            description: 'Minutes after shift end before a clock-out
                              is flagged. `0` disables the check — late clock-outs
                              are never flagged — rather than meaning zero tolerance,
                              which is the opposite of how `0` reads on the two clock-in
                              buffers above. Early clock-out is never restricted at
                              all.

                              '
                          enable_auto_clock_out:
                            type: boolean
                            description: 'The server closes forgotten punches. Surfaced
                              so a client can warn the employee rather than have an
                              auto clock-out look like a lost shift.

                              '
                          auto_clock_out_max_hours:
                            type: integer
                            description: Hours after clock-in at which the punch is
                              closed automatically.
                          auto_clock_out_after_shift_end_minutes:
                            type: integer
                            description: For scheduled shifts, minutes after shift
                              end before auto clock-out.
                          enable_clock_out_reminders:
                            type: boolean
                            description: 'Send a reminder before the shift ends. Independent
                              of `enable_auto_clock_out` — the sweep selects the union
                              of the two flags, so a tenant may receive reminders
                              without the server ever auto-closing a punch. Do not
                              present this as a sub-setting of auto clock-out.

                              '
                          effective_clock_out_reminder_minutes:
                            type: integer
                            minimum: 5
                            description: 'Minutes before scheduled shift end that
                              the reminder is sent. This is the stored `clock_out_reminder_minutes_before`
                              clamped to the reminder sweep''s cron floor, not the
                              raw column — a row holding a lower value (or none) still
                              reminds at the floor, so this is the only number safe
                              to show an employee.

                              '
                  broadcast_alerts:
                    type: object
                    nullable: true
                    description: 'Unread/response badge counts for the Broadcast &
                      Alerts app. `null` when the app is not enabled for the business.
                      Each `count` matches the `all` value the corresponding list
                      API returns in `meta.segment_counts`, so the home badge can''t
                      drift from the list. A `count` may be `null` if its computation
                      fails (the node is still returned).

                      '
                    properties:
                      broadcast:
                        type: object
                        properties:
                          count:
                            type: integer
                            nullable: true
                            description: Received broadcasts the user has not read
                      alert:
                        type: object
                        properties:
                          count:
                            type: integer
                            nullable: true
                            description: Alert responses (acknowledge / safety check-in)
                              the user owes
                          items:
                            type: array
                            maxItems: 10
                            description: 'The response-pending alerts the count covers
                              — ack-required alerts the user has not acknowledged,
                              or safety-check-in alerts the user has not responded
                              to — newest published first, capped at 10 (a preview;
                              the full total is in `count`).

                              '
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                title:
                                  type: string
                  your_tasks:
                    type: object
                    nullable: true
                    description: 'The Home briefing''s "Your tasks": what the caller
                      owes across apps — tasks, courses, onboarding items, documents
                      to sign, inspections, shift offers, Frontline work — ranked
                      overdue first. Null unless an administrator turned the Home
                      briefing on. Must-reads and approvals are in `waiting_on_you`.

                      '
                    properties:
                      items:
                        type: array
                        description: The first five rows.
                        items:
                          type: object
                          description: One item of the worker's day, projected from
                            its owning app. Acted on in that app — this surface only
                            ranks and links.
                          properties:
                            source:
                              type: string
                              description: Which app this row came from.
                              enum:
                              - frontline
                              - inspections
                              - must_reads
                              - tasks
                              - training
                              - shift_offers
                              - approvals
                              - onboarding
                              - signatures
                              example: frontline
                            key:
                              type: string
                              description: Stable cross-source id, for de-dup across
                                pages.
                              example: frontline-1187
                            title:
                              type: string
                              example: Close-out safety walk
                            subtitle:
                              type: string
                              nullable: true
                              description: Location or lane context.
                              example: Store 412
                            priority:
                              type: string
                              enum:
                              - critical
                              - important
                              - normal
                              example: normal
                            overdue:
                              type: boolean
                              example: false
                            due_at:
                              type: string
                              format: date-time
                              nullable: true
                            url:
                              type: string
                              nullable: true
                              description: Where to act on the row, in the app that
                                owns it. Null when that app resolved no route (render
                                no link rather than a broken one).
                              example: "/apps/frontline-execution/my-day"
                            mobile_url:
                              type: string
                              nullable: true
                              description: The row's phone page, where it is not simply
                                the phone form of `url` (an onboarding form). Null
                                otherwise.
                            action_label:
                              type: string
                              description: The verb for the row's action.
                              example: Open
                            rank_reason:
                              type: string
                              nullable: true
                              description: Why the row is ranked where it is.
                              example: Due 5:00 PM.
                            blocked:
                              type: boolean
                              description: Whether the worker has flagged this as
                                "I can't do this". Only a `frontline` row can be flagged;
                                every other source is always false.
                              example: true
                            blocked_reason:
                              type: string
                              nullable: true
                              description: What is blocking it — the reason the worker
                                picked when flagging. Null when not flagged.
                              enum:
                              - no_stock
                              - missing_fixture_or_supplies
                              - equipment_down
                              - not_enough_time
                              - unclear_instructions
                              - other
                              example: missing_fixture_or_supplies
                            blocked_reason_label:
                              type: string
                              nullable: true
                              description: Human-readable label for `blocked_reason`.
                                Null when not flagged.
                              example: Missing fixture or supplies
                            blocked_note:
                              type: string
                              nullable: true
                              maxLength: 500
                              description: The note the worker left for their manager
                                when flagging it ("Anything your manager should know").
                                Optional when flagging, so it can be null on a flagged
                                row; always null on an unflagged one.
                              example: Promo signage never arrived in the delivery
                            blocked_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When it was flagged. Null when not flagged.
                      total:
                        type: integer
                        description: How many rows the whole list holds.
                      degraded_sources:
                        type: array
                        description: Apps whose rows could not be loaded, so the list
                          is incomplete.
                        items:
                          type: string
                  trip:
                    type: object
                    nullable: true
                    description: 'The Home briefing''s "Your assignment": the dated
                      work assignment (a posting, a rotation, a trip) the caller is
                      on or about to start — the one under way, else the next. Null
                      when the caller has none, and unless an administrator turned
                      the Home briefing on.

                      '
                    properties:
                      id:
                        type: integer
                      location:
                        type: object
                        properties:
                          id:
                            type: integer
                            nullable: true
                          name:
                            type: string
                      starts_on:
                        type: string
                        format: date
                      ends_on:
                        type: string
                        format: date
                        nullable: true
                        description: Null while the assignment has no end date.
                      status:
                        type: string
                        enum:
                        - scheduled
                        - active
                      countdown:
                        type: string
                        nullable: true
                        description: '"Starts in 9 days", "Starts tomorrow", "Day
                          3 of 28".'
                      report_to:
                        type: string
                        nullable: true
                        description: Full name of the person to report to on arrival.
                      report_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: When to report, in the time zone of the location.
                      travel:
                        type: array
                        description: The legs of the journey there, in order.
                        items:
                          type: object
                          properties:
                            mode:
                              type: string
                              enum:
                              - flight
                              - helicopter
                              - ferry
                              - train
                              - bus
                              - car
                              - other
                            label:
                              type: string
                              description: '"Flight · Aberdeen → Bergen".'
                            detail:
                              type: string
                              nullable: true
                              description: The departure time as the ticket shows
                                it, and the reference.
                      before_you_go_count:
                        type: integer
                        description: Open pre-start items on the pre-arrival plan
                          opened for this assignment; 0 with no plan.
                  waiting_on_you:
                    type: object
                    nullable: true
                    description: 'The ONE "Waiting on you" (Knowledge Layer E2) —
                      the same list the Home briefing, the rail''s per-app counts
                      and the Inbox''s ?view=waiting (and /m/inbox?view=waiting) show,
                      over what was asked in the last 90 days (older asks: `older`).
                      Two halves: what the caller DECIDES (`to_decide` — every approvals
                      queue whose decision is theirs) and what they were asked to
                      read and confirm and have not (`to_acknowledge_count`, `items`,
                      `counts_by_kind` — must-read posts, broadcasts, alerts, Sites
                      pages, policies and SOPs, each from that app''s own pending
                      list, never from notification rows, so an ask in a muted category
                      is still here). Not summed into `quick_stats.unread_notifications`.
                      `null` if the computation fails, and for a token without `read:notifications`
                      — as are `notifications` and, for a manager, `quick_stats.pending_approvals`
                      (the Inbox is read only with that scope).

                      '
                    properties:
                      count:
                        type: integer
                        description: 'Everything asked in the last 90 days, uncapped
                          — `to_decide.count` plus `to_acknowledge_count`.

                          '
                      to_decide:
                        type: object
                        description: '"To decide" — what the caller decides (the approvals
                          registry: people''s requests, AI suggestions, …). The same
                          number as `quick_stats.pending_approvals`.

                          '
                        properties:
                          count:
                            type: integer
                            description: Everything to decide asked in the last 90
                              days, uncapped
                          counts_by_source:
                            type: object
                            description: The count split by approvals source (e.g.
                              `leave_requests`)
                            additionalProperties:
                              type: integer
                          items:
                            type: array
                            maxItems: 10
                            description: 'A preview, most urgent first (overdue, then
                              soon, then oldest request), capped at 10. Decided where
                              `url` links.

                              '
                            items:
                              "$ref": "#/components/schemas/WaitingOnYouDecideRow"
                      to_acknowledge_count:
                        type: integer
                        description: '"To acknowledge" — everything the caller was
                          asked in the last 90 days to confirm and has not, uncapped
                          (`items` / `counts_by_kind`).

                          '
                      counts_by_kind:
                        type: object
                        description: The "To acknowledge" count split by kind (kinds
                          with nothing waiting are omitted)
                        additionalProperties:
                          type: integer
                      items:
                        type: array
                        maxItems: 10
                        description: '"To acknowledge" preview, most urgent first
                          (overdue, then soonest due, then newest ask), capped at
                          10.

                          '
                        items:
                          "$ref": "#/components/schemas/WaitingOnYouAcknowledgeItem"
                      degraded:
                        type: array
                        description: 'What failed to load just now — acknowledgement
                          kinds and approvals sources — so the list may be missing
                          them

                          '
                        items:
                          type: string
                      older:
                        type: object
                        description: 'Only with `?waiting_on_you_older=1` (the key
                          is absent otherwise). Still open but asked more than 90
                          days ago, so in no count above and no badge — the web Inbox''s
                          collapsed "Older" group.

                          '
                        properties:
                          count:
                            type: integer
                            description: Everything older, uncapped (`to_decide_count`
                              + `to_acknowledge_count`)
                          to_decide_count:
                            type: integer
                          to_acknowledge_count:
                            type: integer
                          to_decide:
                            type: array
                            maxItems: 20
                            description: Decided with `POST /home/waiting_on_you/decide`,
                              as `to_decide.items`
                            items:
                              "$ref": "#/components/schemas/WaitingOnYouDecideRow"
                          to_acknowledge:
                            type: array
                            maxItems: 20
                            description: Same item shape as `waiting_on_you.items`
                            items:
                              "$ref": "#/components/schemas/WaitingOnYouAcknowledgeItem"
                          degraded:
                            type: array
                            items:
                              type: string
                  forms:
                    type: object
                    nullable: true
                    description: 'Forms pending-approval badge. `null` unless the
                      Forms app is enabled for the business AND the caller may approve/reject
                      submissions (admin / manager / Forms App Admin) — the same gate
                      as GET /api/v1/forms/approvals. Returned `null` (never omitted
                      erroneously) if the count computation fails, so the home payload
                      is never dropped.

                      '
                    properties:
                      pending_approval_count:
                        type: integer
                        description: 'Submissions awaiting the caller''s review. Role-aware,
                          matching the size of GET /api/v1/forms/approvals so the
                          badge can''t drift from that list: admins and Forms App
                          Admins count the business-wide pending_review set (submitted
                          + under_review); a manager counts only the under_review
                          submissions from their own direct reports.

                          '
                  notifications:
                    type: object
                    properties:
                      unread_count:
                        type: integer
                        description: Number of unread notifications
                      total_count:
                        type: integer
                        description: Total notifications for user
                      recent:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            title:
                              type: string
                            message:
                              type: string
                            type:
                              type: string
                            priority:
                              type: string
                              enum:
                              - low
                              - normal
                              - high
                              - urgent
                              default: normal
                            created_at:
                              type: string
                              format: date-time
                            read_at:
                              type: string
                              format: date-time
                              nullable: true
                            action_url:
                              type: string
                              nullable: true
                            link_withheld_app:
                              type: string
                              nullable: true
                              description: 'When `action_url` is null because the
                                app it leads into will not let this reader in, that
                                app''s name; null otherwise.

                                '
                  quick_stats:
                    type: object
                    properties:
                      today_shifts:
                        type: integer
                        description: Number of shifts today
                      week_hours:
                        type: number
                        format: float
                        description: Hours worked this week
                      unread_notifications:
                        type: integer
                        description: Unread notification count
                      pending_approvals:
                        type: integer
                        description: 'What the caller decides — the approvals registry''s
                          count, the same number as `waiting_on_you.to_decide.count`
                          and the web home and approvals page (managers/admins only).
                          Was a business-wide count of pending leave requests and
                          attendance records flagged for review, whoever could act.

                          '
                      team_size:
                        type: integer
                        description: Team size (managers/admins only)
                      today_employee_count:
                        type: integer
                        description: Employees scheduled today (managers/admins only)
                  _meta:
                    allOf:
                    - "$ref": "#/components/schemas/HttpEnhancementMeta"
                    - type: object
                      properties:
                        refresh_interval:
                          type: integer
                          description: Recommended refresh interval in seconds
                          example: 300
                        server_time:
                          type: string
                          format: date-time
                          description: Current server time
                        mobile_optimized:
                          type: boolean
                          example: true
                        cache_key:
                          type: string
                          description: Cache key for client-side caching
              examples:
                employee_home:
                  summary: Employee home screen
                  value:
                    user:
                      id: 123
                      email: john.doe@company.com
                      first_name: John
                      last_name: Doe
                      full_name: John Doe
                      role: employee
                      active: true
                      avatar_url:
                      phone: "+1234567890"
                      onboarding_completed: true
                      onboarding_progress: 100
                      preferences: {}
                    business:
                      id: 456
                      name: Example Company
                      subdomain: example
                      timezone: America/New_York
                      logo_url:
                      branding:
                        primary_button_color: "#2e63b3"
                        secondary_button_color: "#6c757d"
                        status_success_color: "#198754"
                        status_error_color: "#dc3545"
                        status_warning_color: "#ffc107"
                        status_info_color: "#0dcaf0"
                        mobile_header_background: "#2e63b3"
                        mobile_header_text: "#FFFFFF"
                        mobile_footer_background: "#FFFFFF"
                        mobile_footer_icon_active: "#2e63b3"
                        mobile_footer_icon_inactive: "#6c757d"
                      settings:
                        shifts_scheduling_enabled: true
                        time_attendance_enabled: true
                        leave_management_enabled: true
                        enabled_marketplace_apps:
                        - epms
                        - training_connect
                        week_start_day: monday
                    dashboard:
                      widgets:
                      - name: quick_actions
                        title: Quick Actions
                        category: platform
                        icon: fas fa-bolt
                        size: col-12
                        priority: 1
                        data:
                          actions:
                          - title: Clock In
                            path: "/attendance/clock_in"
                            icon: fas fa-play
                            variant: primary
                          - title: My Schedule
                            path: "/shifts/my_shifts"
                            icon: fas fa-calendar
                            variant: outline-info
                        data_schema:
                          type: object
                          properties:
                            actions:
                              type: array
                              items:
                                type: object
                                properties:
                                  title:
                                    type: string
                                  path:
                                    type: string
                                  icon:
                                    type: string
                                  variant:
                                    type: string
                        version: '1.2'
                        refresh_interval: 300
                      - name: upcoming_shifts
                        title: Upcoming Shifts
                        category: core_app
                        icon: fas fa-calendar
                        size: col-md-6
                        priority: 3
                        data:
                          shifts:
                          - id: 789
                            title: Morning Shift
                            start_time: '2024-01-15T08:00:00Z'
                            end_time: '2024-01-15T16:00:00Z'
                            location: Main Office
                          total_count: 5
                        refresh_interval: 300
                      - name: quick_attendance
                        title: Time Clock
                        category: core_app
                        icon: fas fa-clock
                        size: col-md-6
                        priority: 2
                        data:
                          current_status:
                            status: scheduled
                            shift:
                              id: 789
                              date: '2024-01-15'
                              start_time: '08:00 AM'
                              end_time: 04:00 PM
                              location: Main Office
                              duration: 8h 0m
                              is_ad_hoc: false
                            can_clock_in: true
                          today_hours: 0.0
                          markdown_summary: 'Next Shift: Today, 08:00 AM - 04:00 PM
                            at Main Office'
                          display_hint: card
                        refresh_interval: 60
                      widget_count: 3
                      performance_metrics:
                        current_week:
                          hours_worked: 32.5
                          shifts_completed: 4
                          on_time_rate: 95.2
                          attendance_rate: 98.1
                        current_month:
                          total_shifts: 18
                          hours_worked: 142.5
                          performance_score: 96.5
                      role_context:
                        current_role: employee
                        permissions:
                        - read_dashboard
                        - view_profile
                        available_actions:
                        - name: view_profile
                          label: View Profile
                          icon: fas fa-user
                        - name: clock_in_out
                          label: Clock In/Out
                          icon: fas fa-clock
                    notifications:
                      unread_count: 3
                      total_count: 25
                      recent:
                      - id: 101
                        title: Schedule Update
                        message: Your shift on Jan 15 has been updated
                        type: shift_update
                        priority: normal
                        created_at: '2024-01-14T10:30:00Z'
                        read_at:
                        action_url: "/shifts/789"
                    quick_stats:
                      today_shifts: 1
                      week_hours: 32.5
                      unread_notifications: 3
                    _meta:
                      api_version: v1
                      generated_at: '2024-01-15T12:00:00Z'
                      refresh_interval: 300
                      server_time: '2024-01-15T12:00:00Z'
                      mobile_optimized: true
                      cache_key: home_123_456_1705320000
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `read:widgets` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: home_data_error
                      message:
                        type: string
                        example: Failed to load home screen data
  "/home/waiting_on_you/decide":
    post:
      tags:
      - Home
      summary: Decide a "To decide" row of Waiting on you
      description: |
        Approve or decline one `waiting_on_you.to_decide.items` row from
        `GET /api/v1/home` (Knowledge Layer C3 — approve anywhere). `source` and
        `id` are the row's own. The approvals registry's one decide path — the
        same one the web and phone "Waiting on you", the approvals page and the
        Teams / Slack cards take: the queue's door re-checked now, the row in
        the caller's own queue right now, a reason where the queue asks one of
        a decline, then the owning app's own action, run as the caller.
        "Not waiting" is one answer for a row already decided and a row that
        was never the caller's, so it reveals nothing about someone else's.

        A token needs the `write:approvals` scope (the native app's sign-in
        tokens carry it); without it the row's `can_approve` / `can_decline`
        read false and this answers 403 `insufficient_permissions`.

        Delete-level (owner decisions 2026-10-04, #6659 and #6721): EVERY decline
        — a decision that ends someone else's request — also requires the delete
        scope of the area the request belongs to: declining a leave request
        `destructive:leave_management`, a timesheet `destructive:timekeeping`, an
        expense report `destructive:mango_spend`, a form submission
        `destructive:forms`, a goal or review `destructive:epms`, a contract
        `destructive:contracts`, an offer `destructive:offer_manager`, and so on
        (an AI suggestion is the one exception: declining it ends nobody's
        request). Approving a nomination asks `destructive:recognition` too.
        Without it the row's `can_approve` / `can_decline` read false for that
        verdict and this answers 403 `insufficient_permissions` naming the scope.
      operationId: decideWaitingOnYou
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - source
              - id
              - verdict
              properties:
                source:
                  type: string
                  description: The row's approvals source key (`to_decide.items[].source`).
                  example: ai_suggestions
                id:
                  type: integer
                  description: The row's id (`to_decide.items[].id`).
                verdict:
                  type: string
                  enum:
                  - approve
                  - reject
                surface:
                  type: string
                  enum:
                  - inbox
                  - approvals_page
                  - home
                  - today
                  - phone
                  - email
                  - teams
                  - slack
                  - ask_ai
                  - chat
                  description: 'The screen the person decides on, when the client
                    knows it. It is what a Chat card says the decision came from;
                    any other value is ignored.

                    '
                reason:
                  type: string
                  description: Required to decline a row whose `decline_needs_reason`
                    is true.
      responses:
        '200':
          description: Decided
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    description: The owning app's own sentence.
                  waiting_on_you:
                    type: object
                    nullable: true
                    description: 'The counts after the decision, so the Home badge
                      moves with it; `null` for a token without `read:notifications`.

                      '
                    properties:
                      count:
                        type: integer
                      to_decide_count:
                        type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`insufficient_permissions` — the token has no `write:approvals`
            scope, or no delete scope for a delete-level verdict (the message names
            it); `queue_unavailable` — no such queue, or its app is off for this workspace.\n"
        '409':
          description: "`not_waiting` — already decided, or not the caller's to decide."
        '422':
          description: "`reason_required` (decline with no reason where the queue
            asks one), `invalid_verdict`, or `decision_refused` (the owning app's
            own refusal, in `error.message`).\n"
  "/home/focus/{id}/snooze":
    post:
      tags:
      - Home
      summary: Snooze a "Do this first" row
      description: |
        Parks one row of `GET /api/v1/home?surface=desktop&section=focus` in the
        caller's own feed, and it comes back when the time is up. The same writer
        as AI Home's Snooze: it needs the row to be in the caller's feed, not
        authority over it, and it changes nothing for anyone else.

        A token needs the `write:approvals` scope; without it the row's
        `can_snooze` reads false and this answers 403 `insufficient_permissions`.
      operationId: snoozeHomeFocusRow
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                hours:
                  type: integer
                  minimum: 1
                  maximum: 168
                  default: 4
                  description: How long, in hours. Kept within 1 hour and 7 days.
      responses:
        '200':
          description: Snoozed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`insufficient_permissions` — the token has no `write:approvals`
            scope."
        '404':
          description: "`not_found` — the row is not in the caller's feed."
  "/home/focus/{id}/dismiss":
    post:
      tags:
      - Home
      summary: Dismiss a "Do this first" row
      description: |
        Closes one row of `GET /api/v1/home?surface=desktop&section=focus`, on AI
        Home's Dismiss rules: never a report's own obligation, and only for
        someone who decides that row. A row with a decision in it is answered
        from "To decide" instead, with `POST /home/waiting_on_you/decide`.

        A token needs the `write:approvals` scope; without it the row's
        `can_dismiss` reads false and this answers 403 `insufficient_permissions`.
      operationId: dismissHomeFocusRow
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why, kept on the row's record.
      responses:
        '200':
          description: Dismissed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`insufficient_permissions` — the token has no `write:approvals`
            scope; `forbidden` — the row is not the caller's to close.\n"
        '404':
          description: "`not_found` — the row is not in the caller's feed."
        '422':
          description: "`decide_instead` — the row has a decision in it; or `unprocessable_entity`
            — it was already decided, or its app needs a reason given through Decline.\n"
  "/home/review/{subject_type}/{id}/still_accurate":
    post:
      tags:
      - Home
      summary: Mark an item from To review still accurate
      description: |
        Marks one item of `GET /api/v1/home?surface=desktop&section=review` as
        still accurate, as Content I own's Still accurate does: its review clock
        restarts without an edit, and its stale finding resolves. Only the
        person the item is listed for (its owner, a member of its owning team,
        or its author while nobody is named) who can open it may do it.

        A token needs the `write:widgets` scope; without it this answers 403
        `insufficient_permissions`.
      operationId: markHomeReviewStillAccurate
      parameters:
      - name: subject_type
        in: path
        required: true
        schema:
          type: string
        description: The item's `subject_type` from the review row, such as `Sop`.
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Marked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  next_review_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: When its review falls due again; null while governance
                      is off for its app.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`insufficient_permissions` — the token has no `write:widgets`
            scope; `forbidden` — the item is not listed for the caller.\n"
        '404':
          description: "`not_found` — no item of a type this can mark has that id."
  "/home/week/preference":
    patch:
      tags:
      - Home
      summary: Turn the weekly wrap on Today off or on for yourself
      description: |
        The signed-in person's own switch for the weekly wrap
        (`GET /api/v1/home?surface=desktop&section=week`), in this company.
        Off, the wrap answers `off_for_you` and is never worked out for them.
        There is no parameter for anyone else, and an impersonated session may
        not change it.

        A token needs the `write:widgets` scope; without it this answers 403
        `insufficient_permissions`.
      operationId: updateHomeWeekPreference
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - enabled
              properties:
                enabled:
                  type: boolean
                  description: false turns the wrap off for the caller; true turns
                    it back on.
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  personal_enabled:
                    type: boolean
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`insufficient_permissions` — the token has no `write:widgets`
            scope; `impersonated` — the session is acting as someone else.\n"
        '422':
          description: "`invalid_parameter` — `enabled` is not true or false."
  "/users/me":
    get:
      tags:
      - Users
      summary: Get current user profile (Enhanced)
      description: |
        Returns the authenticated user's profile and business information, with optional
        `include` fields for enhanced mobile and web app experiences.

        ## Enhanced Features
        Use the `include` query parameter to request additional data:

        - `complete_profile` - Full profile with professional and compensation data
        - `dashboard_summary` - Dashboard stats and quick actions
        - `intelligence_insights` - AI-powered insights and recommendations
        - `preferences_detailed` - Detailed preferences with metadata

        ## Examples
        ```
        GET /api/v1/users/me?include=complete_profile,dashboard_summary
        GET /api/v1/users/me?include=intelligence_insights
        ```
      parameters:
      - name: include
        in: query
        description: |
          Comma-separated list of additional data to include in the response.
          Available options: complete_profile, dashboard_summary, intelligence_insights, preferences_detailed
        required: false
        schema:
          type: string
          example: complete_profile,dashboard_summary
      responses:
        '200':
          description: User profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    "$ref": "#/components/schemas/CurrentUser"
                  business:
                    "$ref": "#/components/schemas/Business"
                  _meta:
                    "$ref": "#/components/schemas/HttpEnhancementMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/users/me/preferences":
    patch:
      tags:
      - Users
      summary: Update user preferences (Enhanced)
      description: |
        Update the authenticated user's preferences with support for category-based updates
        and smart defaults integration.

        ## Enhanced Features
        - Category-specific updates using `?category=notifications`
        - Smart defaults with `?include_smart_defaults=true`
        - Atomic updates to prevent conflicts

        ## Examples
        ```
        PATCH /api/v1/users/me/preferences?category=notifications
        PATCH /api/v1/users/me/preferences?include_smart_defaults=true
        ```
      parameters:
      - name: category
        in: query
        description: |
          Update only a specific preference category.
          Available categories: notifications, availability, communication, privacy
        required: false
        schema:
          type: string
          enum:
          - notifications
          - availability
          - communication
          - privacy
          example: notifications
      - name: include_smart_defaults
        in: query
        description: Include AI-powered smart defaults in the response
        required: false
        schema:
          type: boolean
          example: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - preferences
              properties:
                preferences:
                  type: object
                  description: User preferences to update
                  example:
                    theme: dark
                    notifications_enabled: true
                    language: en
      responses:
        '200':
          description: Preferences updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    properties:
                      id:
                        type: integer
                      preferences:
                        type: object
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/users/{id}":
    get:
      tags:
      - Users
      security:
      - BearerAuth: []
      summary: Get a user's HRIS detail (by employee UID)
      description: |
        The HRIS detail of one person in the caller's business, looked up by
        `mango_employee_id` (the `uid` the list returns — e.g. `E1234567`), not
        the numeric id (that is `GET /users/{id}/profile`). Requires
        `read:people_directory` (`read:users` implies it).

        `legal_entity` (Knowledge Layer X5) is the person's employing legal
        entity: their own override, else the nearest one set on their location
        or a place above it; null when neither names one. `language` is their
        stored SETTING (null = "Not set", they follow their place) — a profile
        field, unlike `/users/me`, whose `language` is the resolved one.
      parameters:
      - name: id
        in: path
        required: true
        description: The person's employee UID (`mango_employee_id`).
        schema:
          type: string
          example: E1234567
      responses:
        '200':
          description: User detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    "$ref": "#/components/schemas/UserDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `read:people_directory`.
        '404':
          description: "`user_not_found` — no one in this business has that UID."
  "/users/by_email/{email}":
    get:
      tags:
      - Users
      security:
      - BearerAuth: []
      summary: Get a user's HRIS detail (by email)
      description: |
        The same payload as `GET /users/{id}`, looked up by email address
        (case-insensitive) among the caller's business's members. Requires
        `read:people_directory`.
      parameters:
      - name: email
        in: path
        required: true
        schema:
          type: string
          format: email
      responses:
        '200':
          description: User detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    "$ref": "#/components/schemas/UserDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `read:people_directory`.
        '404':
          description: "`user_not_found`."
  "/users/{id}/profile":
    get:
      tags:
      - Users
      security:
      - BearerAuth: []
      summary: Mobile profile card for a user (numeric id lookup)
      description: |
        Returns a mobile-friendly profile card for the user with the given
        numeric `User#id` — the value mobile clients receive from feed
        payloads, comment author blocks, and mention search results.

        Distinct from `GET /api/v1/users/{id}` which is the HRIS endpoint
        keyed on `mango_employee_id`. Tenant-scoped: only users with an
        active `UserBusiness` row in the caller's business are resolvable;
        cross-tenant or inactive ids return 404.

        Direct reports are filtered to the caller's business + active
        memberships, so the list never leaks ex-employees or users from
        other tenants the manager may belong to.

        `profile_picture.*` URLs and `mobile_url` are absolute so JSON
        clients can render / open them directly without resolving against
        a base URL.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
        description: Numeric User#id.
      responses:
        '200':
          description: Mobile profile card
          content:
            application/json:
              schema:
                type: object
                required:
                - user
                properties:
                  user:
                    type: object
                    required:
                    - id
                    - name
                    - email
                    - profile_picture
                    - direct_reports
                    - mobile_url
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                        nullable: true
                      email:
                        type: string
                        format: email
                        nullable: true
                      contact:
                        type: string
                        nullable: true
                        description: Primary phone number (User#phone).
                      employee_id:
                        type: string
                        nullable: true
                        description: HRIS-facing mango_employee_id (e.g. E1234567).
                      job_title:
                        type: string
                        nullable: true
                      employment_type:
                        type: string
                        nullable: true
                        description: 'One of EMPLOYEE_TYPES: Full-Time, Part-Time,
                          Seasonal/Temp, Hourly, Salaried, Contractor, Other.'
                      hire_date:
                        type: string
                        format: date
                        nullable: true
                      profile_picture:
                        type: object
                        properties:
                          thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: 40x40 variant; absolute URL.
                          full_url:
                            type: string
                            format: uri
                            nullable: true
                            description: 200x200 variant; absolute URL.
                      primary_location:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          address:
                            type: string
                            nullable: true
                      direct_reports:
                        type: array
                        description: Direct reports filtered to the caller's business
                          + active UserBusiness.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              nullable: true
                            email:
                              type: string
                              format: email
                              nullable: true
                            profile_picture:
                              type: object
                              properties:
                                thumbnail_url:
                                  type: string
                                  format: uri
                                  nullable: true
                                full_url:
                                  type: string
                                  format: uri
                                  nullable: true
                      custom_fields:
                        type: array
                        description: The organization's own profile fields on this
                          person that the caller may see there, as `GET /users/{id}`
                          lists them.
                        items:
                          "$ref": "#/components/schemas/ProfileCustomField"
                      can_message:
                        type: boolean
                        description: 'Whether the caller may start a message with
                          this person, as the web profile''s Message button decides:
                          Messages is theirs to start, no information barrier stands
                          between the two, and the token may write messages (`write:messages`).
                          False on your own profile.'
                      message_restriction:
                        type: string
                        nullable: true
                        description: Why the caller may not message this person when
                          an information barrier is the reason, in the caller's language
                          (the sentence the web profile shows where the Message button
                          would be). The barrier covers Chat too, so a client hides
                          its Message and Chat buttons and shows this instead; it
                          is set whenever Messages or Chat is open to the caller,
                          so a company with Chat and without Messages still gets it.
                          Null otherwise.
                      mobile_url:
                        type: string
                        format: uri
                        description: |
                          Absolute deep link to the user's mobile profile,
                          following the `/m/...` convention used by
                          `mobile_app_url(slug)`. Clients can hand this to
                          the WebView or in-app router.
        '400':
          description: id is not numeric
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          description: User not found or not in the caller business
  "/profile":
    get:
      tags:
      - User Profile Management
      summary: Get complete user profile (redirects to /users/me)
      description: "Redirects to the enhanced /users/me endpoint. This endpoint is
        provided \nfor consistency but clients should use /users/me directly with
        include parameters.\nThe caller's answers to the organization's own profile
        fields are in\n`user.custom_fields` there.\n"
      responses:
        '302':
          description: Redirect to /users/me
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/profile/actions":
    post:
      tags:
      - User Profile Management
      summary: Execute profile actions
      description: "Execute various profile-related actions in a single endpoint.
        This allows\nfor specific operations like avatar updates, skill verification
        requests,\ncareer goal setting, and more.\n\n**Note:** Use `action_type` parameter
        (preferred) or `action` parameter for the action. \nUsing `action_type` is
        recommended as `action` is a reserved Rails parameter.\n"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action_type
              properties:
                action_type:
                  type: string
                  enum:
                  - update_avatar
                  - request_skill_verification
                  - set_career_goal
                  - update_emergency_contact
                  - set_emergency_contact_detention_notice
                  - request_manager_change
                  - update_communication_preferences
                  - request_personnel_file_copy
                  description: The action to perform (preferred parameter)
                  example: update_avatar
                action:
                  type: string
                  enum:
                  - update_avatar
                  - request_skill_verification
                  - set_career_goal
                  - update_emergency_contact
                  - set_emergency_contact_detention_notice
                  - request_manager_change
                  - update_communication_preferences
                  - request_personnel_file_copy
                  description: The action to perform (legacy, use action_type instead)
                  deprecated: true
                  example: update_avatar
                data:
                  type: object
                  description: Action-specific data
                  example:
                    avatar: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...
            examples:
              update_avatar:
                summary: Update profile avatar
                value:
                  action_type: update_avatar
                  data:
                    avatar: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...
              request_skill_verification:
                summary: Request skill verification
                value:
                  action_type: request_skill_verification
                  data:
                    skill_id: 123
              set_career_goal:
                summary: Set career goal
                value:
                  action_type: set_career_goal
                  data:
                    goal: Complete React Certification
                    target_date: '2024-06-01'
              update_emergency_contact:
                summary: Update emergency contact
                value:
                  action_type: update_emergency_contact
                  data:
                    contact_info:
                      name: Jane Doe
                      phone: "+1234567890"
                      relationship: Spouse
              set_emergency_contact_detention_notice:
                summary: Say whether your emergency contact is told if you are arrested
                  or detained at work (California SB 294)
                value:
                  action_type: set_emergency_contact_detention_notice
                  data:
                    answer: 'yes'
              request_personnel_file_copy:
                summary: Ask HR for a free copy of your own personnel file (no data;
                  never under View-as — 403 impersonation_not_allowed; 409 already_requested
                  while one is waiting, details.request is that one; on success data.request
                  is { id, status, requested_at, due_on })
                value:
                  action_type: request_personnel_file_copy
              request_manager_change:
                summary: Ask for a change of manager
                description: |
                  Files a People profile change request for an approver. `reason` is required. `effective_date`
                  (`YYYY-MM-DD`, today or later) is optional, unless the People approval workflow the change meets
                  has "Require Effective Date" on: then a filing without one answers 422 `effective_date_required`.
                  A date that is not a `YYYY-MM-DD` string answers 400 `invalid_effective_date`, and a past date 422
                  `manager_request_invalid`. The filed request is returned with its `effective_date`.
                value:
                  action_type: request_manager_change
                  data:
                    manager_id: 42
                    reason: I moved to the evening team
                    effective_date: '2026-11-02'
      responses:
        '200':
          description: Action executed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Avatar updated successfully
                  data:
                    type: object
                    description: Action-specific response data
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`self_service_disabled` (a profile edit while employee self-service
            is off), or `impersonation_not_allowed` (request_personnel_file_copy under
            View-as).\n"
        '409':
          description: "`already_requested` (request_personnel_file_copy while one
            is waiting; `error.details.request` is that one: { id, status, requested_at,
            due_on }).\n"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/profile/bulk":
    patch:
      tags:
      - User Profile Management
      summary: Bulk update profile sections
      description: |
        Update multiple profile sections atomically in a single transaction.
        This ensures data consistency when updating related profile information.

        Called with an impersonation token, a request whose `personal` section carries a `phone`, `email` or
        `userid` value that differs from the stored one answers 403 `impersonation_cannot_change_account_security`
        and saves nothing; sending the stored values back unchanged is not refused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - updates
              properties:
                updates:
                  type: object
                  description: Profile sections to update
                  properties:
                    personal:
                      type: object
                      description: Personal information updates
                      properties:
                        first_name:
                          type: string
                          example: John
                        last_name:
                          type: string
                          example: Doe
                        phone:
                          type: string
                          example: "+1234567890"
                        time_zone:
                          type: string
                          example: America/New_York
                        employee_type:
                          type: string
                          example: Full-Time
                        job_title:
                          type: string
                          example: Senior Developer
                        department:
                          type: string
                          example: Engineering
                    preferences:
                      type: object
                      description: Preferences updates by category
                      properties:
                        notifications:
                          type: object
                          example:
                            shift_reminders: true
                            schedule_changes: true
                        availability:
                          type: object
                          example:
                            willing_for_extra_shifts: true
                            max_shifts_per_week: 5
                        communication:
                          type: object
                          example:
                            preferred_method: email
                            frequency: immediate
                    professional:
                      type: object
                      description: Professional information updates
                      properties:
                        skills_for_roles:
                          type: object
                          example:
                            developer:
                            - JavaScript
                            - React
                            - Node.js
                    custom_fields:
                      type: object
                      description: |
                        The caller's own answers to the organization's custom profile fields, as
                        `{ field_name: answer }`: a string, a list of strings for a multi-select, and
                        true/false (or yes/no) for a yes-or-no field. Only fields the caller may answer
                        themselves are accepted; a field that needs approval is refused with where to ask
                        for it. Any refusal saves nothing in the whole update.
                      additionalProperties:
                        oneOf:
                        - type: string
                        - type: boolean
                        - type: array
                          items:
                            type: string
                      example:
                        tshirt_size: L
                        languages_spoken:
                        - English
                        - French
            example:
              updates:
                personal:
                  phone: "+1234567890"
                  time_zone: America/New_York
                preferences:
                  notifications:
                    shift_reminders: true
                    schedule_changes: true
                  availability:
                    willing_for_extra_shifts: true
                    max_shifts_per_week: 5
      responses:
        '200':
          description: Bulk update completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Bulk update completed successfully
                  data:
                    type: object
                    properties:
                      updated_sections:
                        type: array
                        items:
                          type: string
                        example:
                        - personal
                        - preferences
                      results:
                        type: object
                        description: |
                          Results for each updated section. `custom_fields` is `{ success, updated }`, the
                          field names saved.
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/ImpersonationAccountSecurityForbidden"
        '422':
          description: |
            A section refused what was sent (`validation_failed`): `error.message` says why, and
            `error.details.errors` lists one sentence per refusal. Nothing in the update was saved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/users/me/security":
    get:
      tags:
      - Account Settings
      summary: Get user security settings
      description: |
        Retrieve comprehensive security settings for the authenticated user including
        two-factor authentication status, trusted devices, security questions, and login history.
      responses:
        '200':
          description: Security settings retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  security:
                    "$ref": "#/components/schemas/UserSecuritySettings"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Account Settings
      summary: Update user security settings
      description: |
        Update security settings including two-factor authentication, trusted devices,
        and security questions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                security:
                  type: object
                  properties:
                    two_factor_auth:
                      type: object
                      properties:
                        enabled:
                          type: boolean
                          description: Enable or disable two-factor authentication
                    trusted_devices:
                      type: object
                      properties:
                        remove_device_id:
                          type: string
                          description: ID of device to remove from trusted list
                    security_questions:
                      type: object
                      properties:
                        question_1:
                          type: string
                          description: First security question
                        answer_1:
                          type: string
                          description: Answer to first security question
                        question_2:
                          type: string
                          description: Second security question (optional)
                        answer_2:
                          type: string
                          description: Answer to second security question (optional)
      responses:
        '200':
          description: Security settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  security:
                    "$ref": "#/components/schemas/UserSecuritySettings"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/ImpersonationAccountSecurityForbidden"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/account_settings/dashboard":
    get:
      tags:
      - Account Settings
      summary: Get account settings dashboard
      description: |
        Retrieve a comprehensive dashboard view of account settings including
        profile completion status, security overview, notification summary, and quick actions.
      responses:
        '200':
          description: Dashboard data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    "$ref": "#/components/schemas/AccountSettingsDashboard"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/account_settings/privacy":
    get:
      tags:
      - Account Settings
      summary: Get privacy settings
      description: |
        Retrieve privacy and data management settings including data sharing preferences,
        visibility settings, and data retention options.
      responses:
        '200':
          description: Privacy settings retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  privacy:
                    "$ref": "#/components/schemas/UserPrivacySettings"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Account Settings
      summary: Update privacy settings
      description: |
        Update privacy and data management settings including data sharing,
        visibility preferences, and data retention options.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                privacy:
                  "$ref": "#/components/schemas/UserPrivacySettingsUpdate"
      responses:
        '200':
          description: Privacy settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  privacy:
                    "$ref": "#/components/schemas/UserPrivacySettings"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/account_settings/communication":
    get:
      tags:
      - Account Settings
      summary: Get communication preferences
      description: |
        Retrieve communication preferences including language, timezone,
        communication style, AI assistant settings, and meeting preferences.
      responses:
        '200':
          description: Communication settings retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  communication:
                    "$ref": "#/components/schemas/UserCommunicationSettings"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    patch:
      tags:
      - Account Settings
      summary: Update communication preferences
      description: |
        Update communication preferences including language, timezone,
        communication style, AI assistant settings, and meeting preferences.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                communication:
                  "$ref": "#/components/schemas/UserCommunicationSettingsUpdate"
      responses:
        '200':
          description: Communication settings updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  communication:
                    "$ref": "#/components/schemas/UserCommunicationSettings"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/onboarding":
    get:
      tags:
      - Onboarding
      summary: Get onboarding progress
      description: |
        Returns the current user's onboarding status, progress percentage, and next steps.
        This endpoint provides a complete overview of the onboarding journey for mobile apps.

        **Required Scopes:** `read:own_profile` — the person's own first-run
        setup (#7183, owner rule 2026-10-06 "every api/v1 door asks a scope").
        A token without it gets `403 insufficient_permissions` ("Required scope:
        read:own_profile") before anything else is checked. The sign-in tokens
        hold it; a browser session is asked no scope.
      responses:
        '200':
          description: Onboarding progress retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  completed:
                    type: boolean
                    description: Whether onboarding is fully completed
                    example: false
                  current_step:
                    type: string
                    nullable: true
                    description: The first step that applies to this person and is
                      not yet done. `null` once `completed` is true.
                    example: availability_shift_preferences
                  progress_percentage:
                    type: integer
                    description: Completion percentage (0-100), over the steps that
                      apply to this person
                    example: 50
                  steps_completed:
                    type: integer
                    description: Number of applicable steps completed
                    example: 2
                  steps_total:
                    type: integer
                    description: Number of steps that apply to this person (the ones
                      `GET /onboarding/steps` lists) — steps skipped for the company
                      or the person are not counted
                    example: 4
                  estimated_completion_time:
                    type: string
                    description: Estimated time to complete remaining steps (3 minutes
                      a step)
                    example: 6 minutes
                  next_action:
                    type: string
                    nullable: true
                    description: URL for the next step to complete; `null` once completed
                    example: "/api/v1/onboarding/steps/availability_shift_preferences"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `read:own_profile` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/onboarding/steps":
    get:
      tags:
      - Onboarding
      summary: List all onboarding steps
      description: "Returns a complete list of onboarding steps with their status,
        accessibility, \nand metadata. Useful for displaying progress indicators and
        step navigation.\n\nOnly the steps that apply to this person are listed: a
        step the company\ndoes not use, and `security_setup` for a person who signs
        in through\ntheir organization, are left out.\n\n**Required Scopes:** `read:own_profile`
        — the person's own first-run\nsetup (#7183, owner rule 2026-10-06 \"every
        api/v1 door asks a scope\").\nA token without it gets `403 insufficient_permissions`
        (\"Required scope:\nread:own_profile\") before anything else is checked. The
        sign-in tokens\nhold it; a browser session is asked no scope.\n"
      responses:
        '200':
          description: Onboarding steps retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  steps:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          description: Step identifier
                          example: profile_completion
                        display_name:
                          type: string
                          description: Human-readable step name
                          example: Complete Your Profile
                        position:
                          type: integer
                          description: Step position in sequence
                          example: 1
                        status:
                          type: string
                          enum:
                          - pending
                          - completed
                          - skipped
                          description: Current step status
                          example: completed
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When step was completed
                        accessible:
                          type: boolean
                          description: Whether step can be accessed now
                          example: true
                        required:
                          type: boolean
                          description: Whether step is required
                          example: true
                        estimated_time:
                          type: string
                          description: Estimated completion time
                          example: 3-5 minutes
                        description:
                          type: string
                          description: Step description
                          example: Complete your basic profile information including
                            name and contact details.
                        url:
                          type: string
                          description: API URL for this step
                          example: "/api/v1/onboarding/steps/profile_completion"
                  total_count:
                    type: integer
                    description: Total number of steps listed
                    example: 4
                  completed_count:
                    type: integer
                    description: Number of completed steps
                    example: 2
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `read:own_profile` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/onboarding/steps/{step_name}":
    get:
      tags:
      - Onboarding
      summary: Get step details
      description: |
        Returns detailed information about a specific onboarding step including
        form fields, current data, validation rules, and help text.

        **What each step's `form_fields` publish:**
        - `profile_completion` — `first_name`, `last_name`, `phone`, `time_zone`
          (editable); `email` and `userid` (read-only, each only when the person
          has one); `job_title` (**read-only**: "Your job title is managed by
          your administrator."). There is no profile photo field — the step
          saves none.
        - `availability_shift_preferences` — `min_hours_weekly` and
          `max_hours_weekly` (`min: 0`, `max: 80`), `max_consecutive_days`
          (`min: 1`, `max: 7`), `weekend_availability` (`all`, `alternating`,
          `occasional`, `none`), `holiday_availability` (`all`, `some`, `none`)
          and `willing_for_extra_shifts`.
        - `notification_preferences` — `email_notifications` and
          `sms_notifications` only; either is `read_only: true` with the reason
          in `help_text` when that channel cannot reach the person. Shift
          reminder timing and quiet hours are no longer published as fields
          (`current_data` still carries `shift_reminder_hours`,
          `quiet_hours_start` and `quiet_hours_end` for apps that read them).
        - `security_setup` — `password`, `password_confirmation`, and
          `current_password` unless it is waived for this caller.

        **A skipped step** answers 200 with `status: "skipped"`, `skipped: true`,
        `skipped_reason`, `accessible: false`, `required: false` and `message`
        ("This step has been skipped: <reason>.") — and none of the form keys.
        The reason is one of: "Not part of this company's onboarding"
        (`role_location_assignment` and `secondary_location_preferences`, always),
        "You sign in through your organization, so there is no password to set"
        (`security_setup` for an SSO person), or "Your company doesn't use this
        step".

        **Required Scopes:** `read:own_profile` — the person's own first-run
        setup (#7183, owner rule 2026-10-06 "every api/v1 door asks a scope").
        A token without it gets `403 insufficient_permissions` ("Required scope:
        read:own_profile") before anything else is checked. The sign-in tokens
        hold it; a browser session is asked no scope.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      responses:
        '200':
          description: Step details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    example: profile_completion
                  display_name:
                    type: string
                    example: Complete Your Profile
                  status:
                    type: string
                    enum:
                    - pending
                    - completed
                    - skipped
                    example: pending
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  accessible:
                    type: boolean
                    example: true
                  required:
                    type: boolean
                    example: true
                  estimated_time:
                    type: string
                    example: 3-5 minutes
                  description:
                    type: string
                    example: Complete your basic profile information including name
                      and contact details.
                  skipped:
                    type: boolean
                    description: Present (true) only when the step is skipped for
                      this person
                    example: true
                  skipped_reason:
                    type: string
                    description: Why the step is skipped; present only on a skipped
                      step
                    example: Your company doesn't use this step
                  message:
                    type: string
                    description: Present only on a skipped step
                    example: 'This step has been skipped: Your company doesn''t use
                      this step.'
                  form_fields:
                    type: array
                    description: Form fields for this step (absent on a skipped step)
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          example: job_title
                        type:
                          type: string
                          description: text, email, tel, password, number, boolean,
                            select or multi_select
                          example: text
                        required:
                          type: boolean
                          example: false
                        label:
                          type: string
                          example: Job Title
                        read_only:
                          type: boolean
                          description: The step shows this field and saves no value
                            for it
                          example: true
                        help_text:
                          type: string
                          description: Present on some fields (a read-only field's
                            reason, a number field's range)
                          example: Your job title is managed by your administrator.
                        min:
                          type: integer
                          description: Number fields with a range only (hours 0, consecutive
                            days 1)
                          example: 0
                        max:
                          type: integer
                          description: Number fields with a range only (hours 80,
                            consecutive days 7)
                          example: 80
                        options:
                          type: array
                          description: Select fields only
                          items:
                            type: object
                            properties:
                              value:
                                example: alternating
                              label:
                                type: string
                                example: Prefer every other weekend
                  current_data:
                    type: object
                    description: Current values for form fields
                    example:
                      first_name: John
                      last_name: Doe
                      email: john.doe@example.com
                      userid:
                      phone: "+12065550100"
                      job_title: Shift Lead
                      time_zone: Eastern Time (US & Canada)
                  validation_rules:
                    type: object
                    description: Validation rules for form fields
                    example:
                      first_name:
                        required: true
                        min_length: 1
                      last_name:
                        required: true
                        min_length: 1
                      time_zone:
                        required: true
                  help_text:
                    type: string
                    nullable: true
                    description: Help text for this step
                    example: This information helps us personalize your experience
                      and ensures you receive important communications.
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Step not accessible, or the token lacks `read:own_profile`
            (`insufficient_permissions`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Onboarding
      summary: Complete onboarding step
      description: |
        Updates and completes a specific onboarding step with the provided data.
        This will validate the input, save the data, and update the user's progress.

        **`profile_completion`** saves `user.first_name`, `last_name`, `phone`
        and `time_zone`. `email` is not written. `job_title` is read-only: a
        posted title that differs from the one the step published is not
        saved, the rest of the step still saves, and the reply names it in
        `blocked_fields: ["job_title"]`. Changing a verified `phone` ends its
        verification; where the organization sends texts and the person has
        not switched them off, the reply says so in `warnings`.

        **`availability_shift_preferences`** (under
        `availability_shift_preferences` or `user`) is refused with 422 when:
        - `min_hours_weekly` or `max_hours_weekly` is not a whole number —
          "Minimum hours per week must be a whole number between 0 and 80" /
          "Maximum hours per week must be a whole number between 0 and 80";
        - `min_hours_weekly` or `max_hours_weekly` is outside 0–80 —
          "Minimum hours per week must be between 0 and 80" / "Maximum hours
          per week must be between 0 and 80";
        - the minimum that will be stored is above the maximum that will be
          stored — "Minimum hours cannot be greater than maximum hours". An
          end that is not posted, or posted blank, is the one already stored;
        - `max_consecutive_days` is not a whole number — "Maximum consecutive
          days must be a whole number between 1 and 7";
        - `max_consecutive_days` is outside 1–7 — "Maximum consecutive days
          must be between 1 and 7";
        - `weekend_availability` is not `all`, `alternating`, `occasional` or
          `none`, or `holiday_availability` is not `all`, `some` or `none` —
          "… must be one of: <the option labels>".

        A value posted back exactly as the person already holds it is never
        refused, whatever it is.

        The older words `never`, `sometimes` and `always` are still accepted and
        stored as `none`, `occasional` (weekend) / `some` (holiday) and `all`.
        Nothing is clamped. A blank is no answer: a blank
        `max_consecutive_days`, `weekend_availability` or
        `holiday_availability` clears the stored answer, and a blank or null
        `min_hours_weekly` / `max_hours_weekly` leaves the stored hours as
        they are.

        **`notification_preferences`** records `email_notifications` and
        `sms_notifications`. `shift_reminder_hours`, `quiet_hours_start` and
        `quiet_hours_end` are still accepted from older apps but are not
        published as fields.

        **A skipped step** answers 200 with `step_completed: true`,
        `step_skipped: true` and `message: "Step has been automatically
        skipped: <reason>"` (reasons as on `GET /onboarding/steps/{step_name}`)
        and saves nothing.

        **A refused save** answers 422 `step_processing_failed`; `error.message`
        is the first reason and `error.details` lists them all.

        **Required Scopes:** `update:own_profile` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope") — and, for the `security_setup` step,
        which sets the password, `write:connections` on top: the scope every door
        that changes a credential asks (#5087). A token without one gets
        `403 insufficient_permissions` ("Required scope: …") before anything is
        saved. The sign-in tokens hold all of them; a browser session is asked no
        scope.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Step-specific data (varies by step)
              example:
                user:
                  first_name: John
                  last_name: Doe
                  phone: "+12065550100"
                  time_zone: Eastern Time (US & Canada)
      responses:
        '200':
          description: Step completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  step_completed:
                    type: boolean
                    example: true
                  step_skipped:
                    type: boolean
                    description: Present (true) only when the step is skipped for
                      this person and nothing was saved
                    example: true
                  message:
                    type: string
                    description: Present only with `step_skipped`
                    example: 'Step has been automatically skipped: Your company doesn''t
                      use this step'
                  current_step:
                    type: string
                    nullable: true
                    description: Step the person is on after this save. `null` once
                      `onboarding_completed` is true.
                    example: notification_preferences
                  progress_percentage:
                    type: integer
                    example: 25
                  onboarding_completed:
                    type: boolean
                    example: false
                  next_step:
                    type: string
                    nullable: true
                    description: The next step after this one that is neither skipped
                      nor already completed
                    example: notification_preferences
                  next_action:
                    type: string
                    nullable: true
                    example: "/api/v1/onboarding/steps/notification_preferences"
                  blocked_fields:
                    type: array
                    description: Posted fields the step did not save. Present only
                      when there is one — today `job_title`, when a title other than
                      the published one was posted. The step itself saved.
                    items:
                      type: string
                    example:
                    - job_title
                  warnings:
                    type: array
                    description: Sentences to show the person about what the save
                      changed. Present only when there is one — today, on `profile_completion`,
                      when a verified phone number was changed or removed and text
                      notifications to the person have stopped. The step itself saved.
                    items:
                      type: string
                    example:
                    - Text notifications are off until you verify your new number
                      in Account Settings › Notifications.
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            `step_not_accessible`: previous steps are not yet completed.

            `impersonation_cannot_complete_account_setup`: the request uses an impersonation token — no step is saved under one.

            `insufficient_permissions`: the token lacks `update:own_profile`, or —
            on the `security_setup` step — `write:connections`.

            `impersonation_cannot_change_account_security`: the request uses an impersonation token and either the step is `security_setup`, or the step is `profile_completion` and `user` carries a `phone`, `email` or `userid` value that differs from the stored one (sending the stored values back unchanged is not refused).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`step_not_found`: the step name is not an onboarding step."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The step was not saved (`step_processing_failed`, or `step_completion_failed`
            / `step_skip_failed`). For `step_processing_failed`, `error.message` is
            the first reason and `error.details` lists every reason.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: step_processing_failed
                  message: Maximum hours per week must be between 0 and 80
                  details:
                  - Maximum hours per week must be between 0 and 80
  "/onboarding/steps/{step_name}/enhanced":
    get:
      tags:
      - Onboarding
      summary: Get enhanced step information with AI assistance
      description: |
        Returns comprehensive step information including smart defaults, AI suggestions,
        and intelligent assistance. This endpoint consolidates multiple features:
        - Smart form pre-filling based on user context
        - AI-powered preference suggestions
        - Similar employee patterns and benchmarks
        - Contextual tips and completion guidance
        - Personalized time estimates

        The fields, ranges, option values and the skipped-step reply are those of
        `GET /onboarding/steps/{step_name}`: `job_title` is read-only, there is
        no profile photo field, and the notification step publishes Email and
        SMS only. Tips and suggestions name only what the step's form offers.

        **Required Scopes:** `read:own_profile` — the person's own first-run
        setup (#7183, owner rule 2026-10-06 "every api/v1 door asks a scope").
        A token without it gets `403 insufficient_permissions` ("Required scope:
        read:own_profile") before anything else is checked. The sign-in tokens
        hold it; a browser session is asked no scope.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      responses:
        '200':
          description: Enhanced step information retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:
                    type: string
                    example: profile_completion
                  display_name:
                    type: string
                    example: Complete Your Profile
                  status:
                    type: string
                    enum:
                    - pending
                    - completed
                    - skipped
                    example: pending
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  accessible:
                    type: boolean
                    example: true
                  required:
                    type: boolean
                    example: true
                  estimated_time:
                    type: string
                    example: 3-5 minutes
                  description:
                    type: string
                    example: Complete your basic profile information including name
                      and contact details.
                  form_fields:
                    type: array
                    description: Enhanced form fields with smart suggestions. Each
                      also carries `read_only`, and `help_text` / `min` / `max` /
                      `options` where the plain step endpoint does.
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                          example: time_zone
                        type:
                          type: string
                          example: select
                        required:
                          type: boolean
                          example: true
                        label:
                          type: string
                          example: Time Zone
                        read_only:
                          type: boolean
                          example: false
                        suggested_value:
                          type: string
                          nullable: true
                          description: On `time_zone` only
                          example: Pacific Time (US & Canada)
                        confidence:
                          type: string
                          enum:
                          - medium
                          - low
                          description: On `time_zone` only — `medium` when the offered
                            zone was detected, else `low`
                          example: medium
                        smart_help:
                          type: string
                          nullable: true
                          description: Field-specific help, when the field has one
                  current_data:
                    type: object
                    description: Current values with smart defaults applied
                    example:
                      first_name: John
                      last_name: Doe
                      email: john.doe@example.com
                      userid:
                      phone:
                      job_title: Shift Lead
                      time_zone: Pacific Time (US & Canada)
                  ai_suggestions:
                    type: object
                    description: Suggestions and insights; the keys depend on the
                      step (these are `profile_completion`'s; empty for steps with
                      none)
                    properties:
                      timezone_confidence:
                        type: string
                        example: medium
                      profile_completeness_score:
                        type: integer
                        example: 75
                      suggested_improvements:
                        type: array
                        items:
                          type: string
                        example:
                        - Complete your phone number for emergency contact
                  smart_defaults:
                    type: object
                    description: Intelligent defaults based on context
                    properties:
                      timezone_detected:
                        type: string
                        nullable: true
                        example: Pacific Time (US & Canada)
                      role_based_suggestions:
                        type: object
                        description: Empty when the person has no role
                        example:
                          role_name: Software Engineer
                          common_locations: []
                  similar_employee_patterns:
                    type: object
                    description: Patterns from similar employees; an empty object
                      when there is nobody to compare with
                    properties:
                      common_availability_patterns:
                        type: object
                        example:
                          avg_min_hours: 20
                          avg_max_hours: 40
                      typical_completion_time:
                        type: string
                        nullable: true
                        example: 2 days
                      success_factors:
                        type: array
                        items:
                          type: string
                        example:
                        - Complete profiles (85% average)
                        - Email notifications enabled (78%)
                  completion_tips:
                    type: array
                    description: Contextual tips for completing this step
                    items:
                      type: string
                    example:
                    - Setting your correct timezone ensures accurate scheduling
                    - Complete contact information helps with emergency situations
                  common_mistakes:
                    type: array
                    description: Common mistakes to avoid
                    items:
                      type: string
                    example:
                    - Forgetting to set timezone (causes scheduling issues)
                    - Using incomplete contact information
                  estimated_completion_time:
                    type: string
                    description: Personalized time estimate
                    example: 3-5 minutes
                  progress_context:
                    type: object
                    description: Progress context and benchmarks
                    properties:
                      steps_remaining:
                        type: integer
                        example: 3
                      estimated_total_time:
                        type: string
                        example: 12 minutes
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Step not accessible, or the token lacks `read:own_profile`
            (`insufficient_permissions`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/onboarding/steps/{step_name}/validate":
    post:
      tags:
      - Onboarding
      summary: Validate step data
      description: |
        Validates onboarding step data without saving it. Useful for real-time
        form validation in mobile apps before final submission.

        It refuses what saving the step refuses — for
        `availability_shift_preferences`, hours that are not a whole number or
        are outside 0–80, consecutive days outside 1–7, a minimum above the
        maximum, and a weekend or holiday answer that is not one of the options
        (`never` / `sometimes` / `always` are still accepted); a value the
        person already holds, posted back unchanged, is never refused. A `job_title` other than the published one is a
        warning, not an error: "Your job title is managed by your
        administrator, so it can't be changed here." A skipped step answers
        200 with `valid: true`, `skipped: true` and `message`.

        **Required Scopes:** `update:own_profile`, like saving the step (#7183) —
        and, for the `security_setup` step, `write:connections` on top. A token
        without one gets `403 insufficient_permissions` ("Required scope: …").
        The sign-in tokens hold all of them; a browser session is asked no scope.
      parameters:
      - name: step_name
        in: path
        required: true
        description: Name of the onboarding step
        schema:
          type: string
          enum:
          - profile_completion
          - role_location_assignment
          - availability_shift_preferences
          - secondary_location_preferences
          - notification_preferences
          - security_setup
          example: profile_completion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Step-specific data to validate
              example:
                user:
                  first_name: John
                  last_name: Doe
                  time_zone: Eastern Time (US & Canada)
      responses:
        '200':
          description: Validation successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Validation successful
                  warnings:
                    type: array
                    description: Non-blocking warnings; they ride a valid answer too
                      (absent on a skipped step)
                    items:
                      type: string
                    example: []
                  skipped:
                    type: boolean
                    description: Present (true) only when the step is skipped for
                      this person
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `update:own_profile`, or — on the `security_setup`
            step — `write:connections` (`insufficient_permissions`); or the step is
            `security_setup` and the request uses an impersonation token (`impersonation_cannot_change_account_security`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: false
                  errors:
                    type: object
                    description: Field-specific validation errors
                    example:
                      max_hours_weekly:
                      - Maximum hours per week must be between 0 and 80
                      max_consecutive_days:
                      - Maximum consecutive days must be between 1 and 7
                  warnings:
                    type: array
                    description: Non-blocking validation warnings
                    items:
                      type: string
                    example:
                    - Your job title is managed by your administrator, so it can't
                      be changed here.
  "/onboarding/reset":
    post:
      tags:
      - Onboarding
      summary: Reset onboarding progress
      description: |
        Resets the user's onboarding progress back to the beginning. The steps
        that apply are asked again (the password step is never re-opened), and
        the person's answers are kept — each step comes back with what they
        last answered. Requires confirmation. Refused under an impersonation
        token.

        **Required Scopes:** `update:own_profile` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). Starting over re-opens the person's
        own setup, which they can redo, so it asks no delete-level scope. A token
        without it gets `403 insufficient_permissions` ("Required scope:
        update:own_profile"). The sign-in tokens hold it; a browser session is
        asked no scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - confirm
              properties:
                confirm:
                  type: string
                  description: Must be "true" to confirm reset
                  example: 'true'
      responses:
        '200':
          description: Onboarding reset successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  reset:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Account setup has been reset. Your answers are kept,
                      so you can change them as you go.
                  current_step:
                    type: string
                    description: The first step that applies to this person
                    example: profile_completion
                  next_action:
                    type: string
                    example: "/api/v1/onboarding/steps/profile_completion"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks `update:own_profile` (`insufficient_permissions`),
            or the request uses an impersonation token (`impersonation_cannot_change_account_security`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`reset_failed`: the reset could not be saved; `error.details`
            lists the reasons."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/forms":
    get:
      tags:
      - Forms
      summary: List published forms (Forms to Fill Out page)
      description: "Returns all published forms for the business, matching exactly
        what appears\non the web **Forms to Fill Out** page (`/apps/forms/published`).\n\n-
        Ordered alphabetically by name (A→Z)\n- Excludes survey-category templates
        and forms linked to a Survey\n- No per-user permission filtering — all authenticated
        users see all published forms\n- Each item includes `page_count` (number of
        pages in the form)\n\n**\U0001F4CB Complete Forms API**: For full Forms App
        API documentation including\ntemplate management, public sharing, and integration
        endpoints, see the\ndedicated Forms App schema at `/api-docs/forms-app-schema.yaml`\n"
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Case-insensitive substring match on form name
        schema:
          type: string
          example: incident report
      - name: category
        in: query
        required: false
        description: Filter by form category slug
        schema:
          type: string
          example: safety
      - name: priority
        in: query
        required: false
        description: Filter by form priority
        schema:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          example: high
      - name: page
        in: query
        required: false
        description: Page number for pagination (default 1)
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, 1–50 (default 20)
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Published forms retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/FormSummaryItem"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/forms/my_submissions":
    get:
      tags:
      - Forms
      summary: List current user's form submissions (My Submissions tab)
      description: |
        Returns the authenticated user's own form submissions, matching the web
        **My Submissions** page (`/apps/forms/my_submissions`).

        - Ordered by `updated_at DESC` — most recently touched first
        - Scope: only submissions belonging to the current user and business
        - The `status` parameter filters by one submission status:

        | `status` value | Meaning |
        |---|---|
        | _(omit)_ or `all` | All Statuses |
        | `draft` | Saved Drafts |
        | `pending_completion` | Paused for someone else to complete |
        | `submitted` | Submitted |
        | `under_review` | Under Review |
        | `changes_requested` | Fields returned for correction |
        | `approved` | Approved |
        | `rejected` | Rejected |
        | `completed` | Every finished submission — approved, or sent on a form that needs no approval (what the web pages call Completed) |

        Each item's `status` is the stored status; `display_state` and
        `status_label` are what the web pages show — `completed` / "Completed"
        for a sent form that needs no approval.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: |
          Status filter. Omit or pass `all` for every status.
          Allowed values: all, draft, pending_completion, submitted, under_review,
          changes_requested, approved, rejected, completed, pending_review
          (every sent submission still waiting on a decision).
        schema:
          type: string
          enum:
          - all
          - draft
          - pending_completion
          - submitted
          - under_review
          - changes_requested
          - approved
          - rejected
          - completed
          - pending_review
          example: draft
      - name: search
        in: query
        required: false
        description: Case-insensitive substring match on form name
        schema:
          type: string
          example: safety audit
      - name: page
        in: query
        required: false
        description: Page number (default 1)
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, 1–50 (default 20)
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Submissions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MySubmissionItem"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
                  segment_counts:
                    type: object
                    description: Per-status tab badge counts. Present ONLY on the
                      all view (status=all or omitted); absent when a specific status
                      is requested. Counts honor the active `search` filter. `all`
                      is the grand total across every status and may exceed the sum
                      of the five buckets when submissions sit in a status without
                      its own tab (e.g. pending_completion).
                    properties:
                      all:
                        type: integer
                        example: 7
                      draft:
                        type: integer
                        example: 1
                      submitted:
                        type: integer
                        example: 2
                      under_review:
                        type: integer
                        example: 1
                      approved:
                        type: integer
                        example: 2
                      rejected:
                        type: integer
                        example: 1
                      completed:
                        type: integer
                        example: 3
                        description: Every finished submission — what status=completed
                          lists
                      pending_review:
                        type: integer
                        example: 1
                        description: Every sent submission still waiting on a decision
                          — what status=pending_review lists
                    required:
                    - all
                    - draft
                    - submitted
                    - under_review
                    - approved
                    - rejected
              examples:
                all_statuses:
                  summary: All statuses (default)
                  value:
                    items:
                    - id: 1042
                      form_id: 7
                      form_name: Equipment Transfer
                      form_description: Transfer equipment custody between sites
                      form_category: operations
                      status: draft
                      status_label: Saved draft
                      completion_percentage: 85
                      submitted_at:
                      reviewed_at:
                      review_notes:
                      updated_at: '2026-06-11T16:30:00Z'
                      created_at: '2026-06-10T08:00:00Z'
                      is_offline_submission: false
                    - id: 2232
                      form_id: 12
                      form_name: Site Safety Audit
                      form_description: Quarterly site-level safety walkthrough
                      form_category: safety
                      status: submitted
                      status_label: Submitted
                      completion_percentage: 100
                      submitted_at: '2026-06-12T09:52:00Z'
                      reviewed_at:
                      review_notes:
                      updated_at: '2026-06-12T09:52:00Z'
                      created_at: '2026-06-12T09:00:00Z'
                      is_offline_submission: false
                    meta:
                      total_count: 6
                      current_page: 1
                      total_pages: 1
                      per_page: 20
                    segment_counts:
                      all: 6
                      draft: 1
                      submitted: 2
                      under_review: 1
                      approved: 1
                      rejected: 1
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Invalid status value
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: invalid_status
                      message:
                        type: string
                        example: 'status must be one of: draft, submitted, under_review,
                          approved, rejected'
  "/forms/assignments":
    get:
      tags:
      - Forms
      summary: List the caller's open recurring-form assignments
      description: |
        Returns the current user's open form assignments — the mobile **Assigned**
        tab. Caller-scoped: never another user's assignments, never tenant-wide.

        **Derived, not stored.** There is no per-user assignment row in the Forms
        schema. A scheduled form only sends notifications when it runs; it does not
        write an assignment record or a `pending_completion` submission. An
        "assignment" here is therefore computed as an active schedule that names the
        caller, whose template is still accepting submissions, and whose audience
        still includes the caller — joined to the caller's own draft for that
        template.

        **Stale assignee lists are filtered out.** A schedule's assignee list is an
        admin-authored blob written when the schedule was saved and never
        revalidated, so it still names people who have since moved out of the target
        department, location, or group. This endpoint applies the **same
        delivery-time audience re-check** the scheduling job applies, so it lists
        only assignments the job itself would actually deliver.

        Excluded: paused schedules, schedules past their end date, and templates that
        are archived, unpublished, closed by schedule, or at their response cap.

        `403` when the Forms app is not enabled or not licensed for the tenant,
        matching the sibling Forms reads.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50.
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: The caller's open assignments
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          description: Scheduled-form id — the assignment's identity.
                        form_id:
                          type: integer
                        form_name:
                          type: string
                        form_description:
                          type: string
                          nullable: true
                        form_category:
                          type: string
                          nullable: true
                        priority:
                          type: string
                          nullable: true
                        page_count:
                          type: integer
                        field_count:
                          type: integer
                        required_field_count:
                          type: integer
                        schedule_descriptor:
                          type: string
                          description: Human cadence, e.g. "Scheduled · every 2 weeks".
                          example: Scheduled · weekly
                        due_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: |
                            When the current cycle closes. The schema holds no
                            per-occurrence due date, so this is the schedule's next
                            reset — the deadline for "the check you do this cycle".
                            The campaign's terminal bound is `ends_at`, kept separate
                            so a weekly form is not reported as due months away.
                        next_run_at:
                          type: string
                          format: date-time
                          nullable: true
                        last_run_at:
                          type: string
                          format: date-time
                          nullable: true
                        ends_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Schedule's terminal bound, or null when open-ended.
                        draft_submission_id:
                          type: integer
                          nullable: true
                          description: The caller's draft for this template, or null.
                        completion_percentage:
                          type: integer
                          nullable: true
                          description: Null when not started.
                        status:
                          type: string
                          enum:
                          - due_today
                          - assigned_to_you
                          - action_needed
                          description: |
                            `action_needed` when a draft exists (that outranks the
                            date), else `due_today` when the cycle closes today,
                            else `assigned_to_you`.
                        action:
                          type: string
                          enum:
                          - start
                          - continue_draft
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      current_page:
                        type: integer
                      total_pages:
                        type: integer
                      per_page:
                        type: integer
              example:
                items:
                - id: 73
                  form_id: 799
                  form_name: Employee Information Update
                  form_description: Request changes to your personal information on
                    file
                  form_category: hr
                  priority: normal
                  page_count: 1
                  field_count: 4
                  required_field_count: 0
                  schedule_descriptor: Scheduled · weekly
                  due_at: '2026-08-17T19:46:49Z'
                  next_run_at: '2026-08-17T19:46:49Z'
                  last_run_at: '2026-08-10T19:46:49Z'
                  ends_at: '2026-12-12T19:46:49Z'
                  draft_submission_id:
                  completion_percentage:
                  status: assigned_to_you
                  action: start
                meta:
                  total_count: 1
                  current_page: 1
                  total_pages: 1
                  per_page: 20
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/forms/categories":
    get:
      tags:
      - Forms
      summary: List form categories with published-form counts
      description: |
        Returns the canonical list of form categories (the same set the web form
        builder and Forms to Fill Out filter use), each with the number of published
        forms in that category for the caller's business.

        - The full canonical list is always returned, including categories with `form_count: 0`
        - Items follow the platform's defined category order (not sorted by count or name)
        - `form_count` uses the same exclusions as `GET /api/v1/forms`
          (published only; excludes survey-category and survey-linked forms), so a
          category's `form_count` equals the `meta.total_count` of
          `GET /api/v1/forms?category=<value>`
        - Categories are not tenant-configurable today
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/FormCategoryItem"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Number of categories returned (the full canonical
                          list)
                        example: 14
              examples:
                default:
                  summary: Categories with published-form counts
                  value:
                    items:
                    - value: general
                      label: General
                      form_count: 4
                    - value: safety
                      label: Safety
                      form_count: 2
                    - value: hr
                      label: HR
                      form_count: 6
                    - value: it
                      label: IT
                      form_count: 0
                    meta:
                      total_count: 14
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/forms/approvals":
    get:
      tags:
      - Forms
      summary: List submissions pending the current reviewer's approval
      description: |
        Returns form submissions awaiting review/approval — the mobile
        **Approvals** queue. Mirrors the web Submissions review queue.

        - **Who can open it.** Admins and Forms App Admins; managers, while the
          Forms Manage tier includes them; a form's owner, owning team or
          co-owner; anyone named as an approver on an active approval workflow,
          or on the step a submission is waiting on; and anyone with a direct
          report whose submission is waiting for a decision. Everyone else gets
          `403`.
        - **What each caller sees:**
          - **Admins and Forms App Admins** see the business-wide set awaiting a
            decision (statuses `submitted` and `under_review` on forms that
            require approval).
          - **Everyone else** sees that set narrowed to the rows they can decide —
            their reports', forms they own or co-own, and rows waiting on a step
            that names them.
        - A person a form's approval workflow names for the step a submission is
          on also sees that submission here; under a workflow only the current
          step's approver may approve it (others may reject or return fields).
        - Ordered **oldest-waiting-first** (longest in the queue at the top).
        - The home-screen pending-approval badge (`GET /api/v1/home` →
          `forms.pending_approval_count`) counts exactly this same per-role set.

        **Filters.** All are optional and compose with `AND`; omitting every one
        returns the unfiltered queue. They narrow the caller's already-authorized
        queue and can never widen it — `?user_id=` on a manager's queue can only
        pick one of their own reportees. A value that cannot be a filter (an
        unparseable date, an array/hash-shaped `?template_id[a]=b`) is **dropped**
        from the query: the response is a `200` with that filter simply not
        applied, never a `500` and never a silently-empty list.
      security:
      - BearerAuth: []
      parameters:
      - name: overdue
        in: query
        required: false
        description: |
          `true` returns only submissions that have been awaiting review longer
          than the server-side review SLA (currently 7 days, measured on
          `submitted_at` falling back to `created_at`). Identical to the web
          review queue's Overdue filter. Do not hardcode the threshold on the
          client — read the per-item `overdue` flag instead.
        schema:
          type: boolean
      - name: template_id
        in: query
        required: false
        description: Only submissions of this form template
        schema:
          type: integer
      - name: user_id
        in: query
        required: false
        description: Only submissions from this submitter
        schema:
          type: integer
      - name: date_from
        in: query
        required: false
        description: |
          Inclusive start of the submitted-date window (`YYYY-MM-DD`), measured
          on the same clock as `submitted_at` (falling back to `created_at`).
        schema:
          type: string
          format: date
      - name: date_to
        in: query
        required: false
        description: Inclusive end of the submitted-date window (`YYYY-MM-DD`)
        schema:
          type: string
          format: date
      - name: search
        in: query
        required: false
        description: Case-insensitive match on the form name OR the submitter's name
        schema:
          type: string
      - name: page
        in: query
        required: false
        description: Page number (default 1)
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, 1–50 (default 20)
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Pending approvals retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ApprovalItem"
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/PaginationMeta"
                    - type: object
                      properties:
                        overdue_count:
                          type: integer
                          description: |
                            Overdue submissions in the whole filtered set (not just
                            this page) — badge the queue and populate an "Overdue"
                            tile from this one call.
                          example: 4
              examples:
                queue:
                  summary: Reviewer's pending queue
                  value:
                    items:
                    - id: 2231
                      reference: "#2231"
                      form_id: 7
                      form_name: Site Safety Audit
                      form_description: Quarterly site-level safety walkthrough
                      form_category: safety
                      priority: high
                      submitter: J. Rivera
                      submitter_email: jrivera@example.com
                      submitter_photo_url: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/.../avatar.jpg
                      status: under_review
                      status_label: Under review
                      requires_approval: true
                      overdue: true
                      submitted_at: '2026-06-18T09:00:00Z'
                      waiting_since: '2026-06-18T09:00:00Z'
                      updated_at: '2026-06-18T09:05:00Z'
                      created_at: '2026-06-18T08:40:00Z'
                    meta:
                      total_count: 4
                      current_page: 1
                      total_pages: 1
                      per_page: 20
                      overdue_count: 4
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/forms/{id}":
    get:
      tags:
      - Forms
      summary: Get form template for mobile rendering
      description: |
        Returns complete form template with fields optimized for mobile rendering.
        Includes a draft submission for offline support and mobile-specific configuration.

        The response includes all data needed to render the form offline and handle submissions.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      - name: submission_id
        in: query
        required: false
        description: Chooses the source for each field's `submission_data` value (always
          present in the response). When supplied, values come from THIS submission
          (for resume/edit) — scoped to the caller's OWN submissions of this form;
          a blank, missing, or other-user submission_id yields `null`. When omitted,
          values come from the caller's DRAFT submission for this form; if there is
          no draft data, every field's `submission_data` is `null`.
        schema:
          type: integer
          example: 789
      responses:
        '200':
          description: Form template retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Form data optimized for rendering
                properties:
                  template:
                    "$ref": "#/components/schemas/FormTemplate"
                  draft_submission:
                    "$ref": "#/components/schemas/FormSubmission"
                    nullable: true
                    description: 'Existing draft submission for progress restoration.
                      NOTE: the `submission_data` property is intentionally omitted
                      here — each field in `fields[]` now carries its own `submission_data`
                      (sourced from this draft or the requested submission), so the
                      map is not repeated on this object.'
                  mobile_config:
                    type: object
                    description: Mobile-specific rendering configuration
                    properties:
                      offline_capable:
                        type: boolean
                        description: Whether the form supports offline mode
                        example: true
                      voice_input_enabled:
                        type: boolean
                        description: Whether voice input is available
                        example: true
                      photo_capture_enabled:
                        type: boolean
                        description: Whether photo capture is available
                        example: true
                      gps_enabled:
                        type: boolean
                        description: Whether GPS location is required
                        example: false
                      estimated_time_minutes:
                        type: integer
                        description: Estimated completion time
                        example: 10
                  theme:
                    type: object
                    description: The colour picked in the form's Design modal (a custom
                      palette's Brand primary, or the chosen preset's). Both values
                      are `null` when the form follows the business theme — keep the
                      business branding.
                    properties:
                      brand_primary:
                        type: string
                        nullable: true
                        description: The picked brand colour as a
                        example: "#f5c518"
                      brand_primary_fill:
                        type: string
                        nullable: true
                        description: The same colour moved until white button text
                          clears it (WCAG AA, 4.5:1) — the fill the phone web fill
                          page paints behind its buttons, links and checked controls.
                          `null` when the form follows the business theme.
                        example: "#7d6302"
                  prefill_data:
                    type: object
                    description: Pre-filled field values
                    nullable: true
                    additionalProperties: true
                required:
                - template
                - mobile_config
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/forms/{id}/preview":
    get:
      tags:
      - Forms
      summary: Preview a form (lightweight metadata + field list)
      description: |
        Lightweight preview of a form: header metadata plus the ordered field
        list with **label and type only** — no field configuration, validation,
        options, draft, or per-user state, and (unlike `GET /forms/{id}`) it does
        NOT create a draft submission. Use it to show a form's shape before the
        user opens it to fill.

        Access is gated like `GET /forms/{id}` (the Forms app must be enabled for
        the business and visible to the user — the same access model as the web),
        so the field structure of an inaccessible form is not exposed.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Form preview retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    example: 123
                  name:
                    type: string
                    example: Site Safety Audit
                  category:
                    type: string
                    nullable: true
                    example: operations
                  page_count:
                    type: integer
                    description: Number of pages (page_break count + 1)
                    example: 2
                  field_count:
                    type: integer
                    description: Total number of fields (equals the size of `fields`)
                    example: 12
                  estimated_time_minutes:
                    type: integer
                    description: Estimated time to complete, in minutes
                    example: 8
                  shareable_link:
                    type: string
                    nullable: true
                    description: 'Public share URL (`/f/{token}`) for the form, or
                      `null` when the form is not publicly accessible (not published,
                      sharing disabled, public access off, or no token issued).

                      '
                    example: https://officechat.workforce.mangoapps.com/f/abc123
                  qr_code_image_url:
                    type: string
                    nullable: true
                    description: 'Absolute URL of the form''s QR-code image (PNG),
                      encoding the public share link. Present only when the form is
                      publicly accessible (same gate as `shareable_link`); `null`
                      otherwise. Keyed by the form''s public access token (not its
                      numeric id) so a mobile client with no web session can load
                      it directly.

                      '
                    example: https://officechat.workforce.mangoapps.com/apps/forms/templates/Xlv6kQQt1RSiHKB0kQUsiPmLcVm_l2Cu/qr_code?format=png
                  fields:
                    type: array
                    description: 'All fields in display (position) order, each with
                      label and type only. No other field information is included.

                      '
                    items:
                      type: object
                      properties:
                        label:
                          type: string
                          nullable: true
                          description: Field label (null for unlabeled fields such
                            as page breaks)
                          example: Full Name
                        type:
                          type: string
                          description: Field type
                          example: text
                      required:
                      - type
                    example:
                    - label: Full Name
                      type: text
                    - label: Site
                      type: select
                    - label:
                      type: page_break
                    - label: Notes
                      type: textarea
                required:
                - id
                - name
                - page_count
                - field_count
                - estimated_time_minutes
                - fields
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/forms/{id}/submit":
    post:
      tags:
      - Forms
      summary: Submit completed form
      description: |
        Submit a completed form, either as a new submission or sync from offline mode.
        Supports both immediate submission and offline sync scenarios.

        For offline sync, include submission_id and set offline_sync=true.

        Media fields (file / image / video / audio / signature / gallery): send
        the request as `multipart/form-data` and put the FILE PART in the
        field's own key — `submission[submission_data][site_photo]=@photo.jpg`
        for a single field, `submission[submission_data][damage_photos][]=@…`
        (repeated) for a gallery. Works with every authentication method,
        Bearer tokens included. The same shape is accepted by
        `POST /forms/{id}/submissions` and `PATCH /form_submissions/{id}`.
        The server validates every file (all-or-nothing, 422
        `invalid_attachments` on an unsupported type, a file over the field's
        size limit, a file posted into a field that does not take one, or a
        file posted under a key that is not a field on this form at all), caps
        one request at 50 file parts, stores them, creates the file records, and
        rewrites those keys to a
        `{ uploaded, file_id, filename, content_type, file_size }` reference.

        A payload that carries NOTHING but file parts leaves every stored answer
        alone — it reads as "add these files", not as "replace the answers with
        nothing". Clearing a draft's answers is an explicit
        `submission_data: {}` (JSON), as it always was.
        Values already in reference form are left as-is (re-send them to keep a
        gallery's existing photos). A blob `signed_id` string is also accepted
        in the same key for a client that already holds one; the generic
        `POST /rails/active_storage/direct_uploads` endpoint is disabled (403)
        and is not a way to obtain one.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      - name: submission_id
        in: query
        required: false
        description: Existing draft submission ID (for offline sync)
        schema:
          type: integer
          example: 456
      - name: offline_sync
        in: query
        required: false
        description: Indicates this is an offline sync operation
        schema:
          type: boolean
          default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission:
                  type: object
                  properties:
                    submission_data:
                      type: object
                      description: Form field values as key-value pairs
                      example:
                        employee_name: John Doe
                        incident_date: '2024-01-15'
                        description: Equipment malfunction in warehouse
                device_id:
                  type: string
                  description: Mobile device identifier
                  example: mobile-123abc
              required:
              - submission
          multipart/form-data:
            schema:
              type: object
              description: 'The same body, form-encoded — and the ONLY content type
                a file part can be sent on (a `format: binary` property inside a JSON-only
                requestBody is something no generated client, and no Swagger UI, can
                post). Each field takes its bracketed name: `submission[submission_data][full_name]`
                for an answer, `submission[submission_data][site_photo]` for a single
                file, repeated `submission[submission_data][damage_photos][]` for
                a gallery. At most 50 file parts per request.'
              properties:
                submission[submission_data][<media_field>]:
                  type: string
                  format: binary
                  description: The file for a single-file media field (file / image
                    / video / audio / signature). The server validates it (all-or-nothing),
                    stores it, creates the file record and rewrites the key to a `{
                    uploaded, file_id, filename, content_type, file_size }` reference.
                    A file posted under a key that is not a media field on this template
                    is refused with 422 `invalid_attachments`.
                submission[submission_data][<gallery_field>][]:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Repeated file parts for a gallery / multi-file field.
                    Send the already-uploaded photos' reference hashes back in the
                    same key to keep them.
                submission[submission_data][<text_field>]:
                  type: string
                  description: Any non-media answer, form-encoded.
      responses:
        '201':
          description: Form submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Form submitted successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  sync_status:
                    type: string
                    enum:
                    - immediate
                    - synced
                    example: immediate
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/forms/{id}/submissions":
    post:
      tags:
      - Forms
      summary: Create or update draft submission
      description: |
        Create a new draft submission or update an existing one for offline support.
        This endpoint allows mobile apps to save form progress locally and sync later.

        Pass `offline_sync=true` ONLY when saving a draft that was composed while
        the device was offline; an online client should omit it (or send false) so
        the draft is not mislabeled as an offline submission. On an existing draft
        the flag can only be turned on — an online edit never downgrades a draft
        that was genuinely created offline.
      parameters:
      - name: id
        in: path
        required: true
        description: Form template ID
        schema:
          type: integer
          example: 123
      - name: offline_sync
        in: query
        required: false
        description: Set true only when saving a draft created while offline. Defaults
          to online (false).
        schema:
          type: boolean
          default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission:
                  type: object
                  properties:
                    submission_data:
                      type: object
                      description: Partial form field values
                      example:
                        employee_name: John Doe
                        incident_date: '2024-01-15'
              required:
              - submission
          multipart/form-data:
            schema:
              type: object
              description: 'The same body, form-encoded — and the ONLY content type
                a file part can be sent on (a `format: binary` property inside a JSON-only
                requestBody is something no generated client, and no Swagger UI, can
                post). Each field takes its bracketed name: `submission[submission_data][full_name]`
                for an answer, `submission[submission_data][site_photo]` for a single
                file, repeated `submission[submission_data][damage_photos][]` for
                a gallery. At most 50 file parts per request.'
              properties:
                submission[submission_data][<media_field>]:
                  type: string
                  format: binary
                  description: The file for a single-file media field (file / image
                    / video / audio / signature). The server validates it (all-or-nothing),
                    stores it, creates the file record and rewrites the key to a `{
                    uploaded, file_id, filename, content_type, file_size }` reference.
                    A file posted under a key that is not a media field on this template
                    is refused with 422 `invalid_attachments`.
                submission[submission_data][<gallery_field>][]:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Repeated file parts for a gallery / multi-file field.
                    Send the already-uploaded photos' reference hashes back in the
                    same key to keep them.
                submission[submission_data][<text_field>]:
                  type: string
                  description: Any non-media answer, form-encoded.
      responses:
        '201':
          description: Draft submission created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft saved successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '200':
          description: Draft submission updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft saved successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/forms/submissions/{id}":
    delete:
      tags:
      - Forms
      summary: Delete a draft submission
      description: |
        Delete a form submission. Two gates apply, in order:

        1. **Owner-only** — only the user who CREATED the submission may delete
           it (a reviewer/admin cannot delete someone else's draft here) →
           `403` with error code `access_denied`.
        2. **Draft-only** — the submission must still be a `draft`. Once it is
           submitted / under_review / approved / rejected it is part of the
           review record and cannot be deleted → `422` with error code
           `cannot_delete` (the current `status` is returned in `error.details`).

        Deleting cascades to the submission's uploaded files. Requires the
        `write:forms` scope. (The same submission's GET and PATCH are also
        available at `/form_submissions/{id}`.)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Draft submission deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft submission deleted
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/form_submissions/{id}":
    get:
      tags:
      - Forms
      summary: Get submission details
      description: |
        Get details of a specific form submission including all field values and files.
        Used to view completed submissions and track submission status.

        Access mirrors the web Submissions surface: the submission's owner, or any
        admin / manager / Forms App Admin in the business, may view it. Any other
        user receives 403. Sensitive (manager-/submitter-restricted) field values
        are stripped from `submission_data` for viewers not permitted to see them.

        **submission_data shape (this endpoint only):** unlike the write/draft
        endpoints (which return a flat `field_name → value` map), the detail
        response expands each answer into an object carrying the field's `label`,
        submitted `value`, and `type` — `{ field_name: { label, value, type } }`.
        Keys are ordered by the template's field position (matching the web detail
        view), with any orphaned data (no matching field) appended last.
        The response's `submission.submitted_by` carries the submitter's name + email + photo_url.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Submission retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  submission:
                    allOf:
                    - "$ref": "#/components/schemas/FormSubmission"
                    - "$ref": "#/components/schemas/FormSubmissionDecisionCapabilities"
                    - type: object
                      properties:
                        can_approve:
                          type: boolean
                          description: 'May this caller approve it now (`Forms::SubmissionAccess#approvable?`)
                            — false for someone who may not decide it, on a decided
                            row, and for a reviewer whose workflow step it is not.
                            Needs `write:forms`.

                            '
                        submitted_by:
                          type: object
                          nullable: true
                          description: 'Who submitted the form — name + email + photo.
                            Resolves to the respondent email (or "Anonymous User")
                            for anonymous submissions. photo_url is the submitter''s
                            thumbnail avatar (a ui-avatars fallback when no photo
                            is set), or null for anonymous submissions.

                            '
                          properties:
                            name:
                              type: string
                              nullable: true
                            email:
                              type: string
                              nullable: true
                            photo_url:
                              type: string
                              nullable: true
                        submission_data:
                          type: object
                          description: 'Per-field answers keyed by field_name. Each
                            value is an object with the field''s label, submitted
                            value, and type (the expanded detail shape, not the flat
                            write-endpoint map).

                            '
                          additionalProperties:
                            type: object
                            properties:
                              label:
                                type: string
                                nullable: true
                                description: Field label from the template definition
                              value:
                                nullable: true
                                description: 'Submitted value — a string or array
                                  for simple fields; a file-reference object/array
                                  for media fields (each reference includes a `url`
                                  to view/download the file); and, for `matrix` and
                                  Likert-mode `scale` fields, an ORDERED array of
                                  { label, value } pairs where `label` is the creator''s
                                  row/statement label and `value` is the chosen column/option
                                  label (an array of labels for a checkbox-grid or
                                  multiple-select matrix row). Pairs are returned
                                  in the creator''s configured row/statement order.
                                  A range-mode `scale` returns a plain scalar.

                                  '
                              type:
                                type: string
                                nullable: true
                                description: Field type (text, image, gallery, signature,
                                  select, …)
                          example:
                            site_photo:
                              label: Site photo
                              type: image
                              value:
                                uploaded: true
                                file_id: 36
                                filename: photo.png
                                content_type: image/png
                                file_size: 20480
                                url: "/rails/active_storage/blobs/redirect/eyJf…/photo.png"
                            notes:
                              label: Notes
                              type: textarea
                              value: All clear on the east wing.
                  form:
                    type: object
                    description: Summary information for a form template
                    properties:
                      id:
                        type: integer
                        description: Unique form template ID
                        example: 123
                      name:
                        type: string
                        description: Form name
                        example: Incident Report
                      description:
                        type: string
                        description: Form description
                        example: Report workplace incidents and safety concerns
                      category:
                        type: string
                        description: Form category
                        example: safety
                      priority:
                        type: string
                        enum:
                        - urgent
                        - high
                        - normal
                        - low
                        description: Form priority level
                        example: high
                      status:
                        type: string
                        enum:
                        - draft
                        - published
                        - archived
                        description: Template status
                        example: published
                      field_count:
                        type: integer
                        description: Total number of fields in the form
                        example: 8
                      required_field_count:
                        type: integer
                        description: Number of required fields
                        example: 5
                      estimated_time_minutes:
                        type: integer
                        description: Estimated completion time in minutes
                        example: 10
                      has_file_uploads:
                        type: boolean
                        description: Whether the form supports file uploads
                        example: true
                      sharing_enabled:
                        type: boolean
                        description: Whether the form can be shared publicly
                        example: false
                      requires_approval:
                        type: boolean
                        description: Whether submissions require approval
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: Form creation timestamp
                        example: '2024-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: Last update timestamp
                        example: '2024-01-15T10:00:00Z'
                    required:
                    - id
                    - name
                    - category
                    - priority
                    - status
                    - field_count
                    - estimated_time_minutes
                  timeline:
                    type: array
                    description: |
                      Status timeline shown on the web "View details" page. Begins
                      with a "Form Submitted" event once sent, followed by a single
                      review event reflecting the current state (approved / fields
                      returned for correction / rejected) when reviewed. A draft its
                      sender withdrew carries a `withdrawn` event; a submission
                      waiting for a decision again carries `reopened_for_review`
                      (an approval taken back) and/or `returns_taken_back` (a return
                      for correction taken back), each with who did it and their
                      reason in `note`.
                    items:
                      "$ref": "#/components/schemas/SubmissionTimelineEvent"
                  workflow_step_runs:
                    type: array
                    description: |
                      What each step of the form's workflow did for this submission,
                      newest first, at most 20: the "Workflow steps" card on the web and
                      phone submission pages. Only people who manage the form see it
                      (admins, Forms app admins, and the form's creator, owner, owning
                      team and co-owners); for everyone else it is an empty array.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        step_name:
                          type: string
                          nullable: true
                          description: The step's name; null for an unnamed step.
                        step_label:
                          type: string
                          description: What the web card prints for the step — its
                            name, or "Unnamed step" in the reader's language.
                          example: Email the safety lead
                        outcome:
                          type: string
                          enum:
                          - done
                          - skipped
                          - failed
                        outcome_label:
                          type: string
                          example: Skipped
                        detail:
                          type: string
                          nullable: true
                          description: What the step produced, why it was skipped,
                            or the error (up to 500 characters).
                        trigger_status:
                          type: string
                          description: The submission status that ran the step.
                          example: approved
                        trigger_label:
                          type: string
                          description: That status in words, in the reader's language;
                            the web card reads it as "When approved".
                          example: Approved
                        created_at:
                          type: string
                          format: date-time
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — not the owner and not an admin/manager/Forms App Admin
            (`forbidden`). Someone the submission ASKED to decide it who may no
            longer open it is told why instead: that it was withdrawn, returned,
            rejected or approved ("Nothing on this submission is waiting on you
            any more — it was rejected."), or that their own access to it ended.
        '404':
          "$ref": "#/components/responses/NotFound"
    patch:
      tags:
      - Forms
      summary: Update draft submission
      description: |
        Update a draft form submission for offline editing and progress saving.
        Only the submitter's own draft or rejected submissions can be updated.
        A submission returned for changes (`changes_requested`) answers 422
        `returned_report_no_draft` — send the corrections with
        `POST /forms/{id}/submit`. A draft on a form that is no longer
        accepting submissions answers 403 `form_not_available`, and one whose
        audience no longer includes the caller 403 `access_denied`.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission:
                  type: object
                  properties:
                    submission_data:
                      type: object
                      description: Updated form field values
                      example:
                        employee_name: John Doe
                        incident_date: '2024-01-15'
                        description: Updated description
              required:
              - submission
          multipart/form-data:
            schema:
              type: object
              description: 'The same body, form-encoded — and the ONLY content type
                a file part can be sent on (a `format: binary` property inside a JSON-only
                requestBody is something no generated client, and no Swagger UI, can
                post). Each field takes its bracketed name: `submission[submission_data][full_name]`
                for an answer, `submission[submission_data][site_photo]` for a single
                file, repeated `submission[submission_data][damage_photos][]` for
                a gallery. At most 50 file parts per request.'
              properties:
                submission[submission_data][<media_field>]:
                  type: string
                  format: binary
                  description: The file for a single-file media field (file / image
                    / video / audio / signature). The server validates it (all-or-nothing),
                    stores it, creates the file record and rewrites the key to a `{
                    uploaded, file_id, filename, content_type, file_size }` reference.
                    A file posted under a key that is not a media field on this template
                    is refused with 422 `invalid_attachments`.
                submission[submission_data][<gallery_field>][]:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Repeated file parts for a gallery / multi-file field.
                    Send the already-uploaded photos' reference hashes back in the
                    same key to keep them.
                submission[submission_data][<text_field>]:
                  type: string
                  description: Any non-media answer, form-encoded.
      responses:
        '200':
          description: Draft submission updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Draft updated successfully
                  submission:
                    "$ref": "#/components/schemas/FormSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          "$ref": "#/components/responses/BadRequest"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          "$ref": "#/components/responses/ValidationError"
  "/form_submissions/{id}/approve":
    post:
      tags:
      - Forms
      summary: Approve a form submission
      description: |
        Approve a submission that is awaiting review, mirroring the web
        **Approval** flow (Approve action) and the native Approvals screen
        (swipe-right / "Approve").

        **Authorization:** the caller must be able to decide this submission —
        the rule the web uses (`Forms::SubmissionAccess#decidable?`): an admin or
        Forms App Admin, one of the form's own people (owner, owning team,
        co-owners, or its creator while inside the Forms Manage tier), the
        submitter's manager, or a person the form's approval workflow names for
        the step the submission is on (`write:forms` scope). A person named on
        another step of its workflow receives `403` with code
        `not_current_approver`. Its message names the step the submission is
        waiting on and that step's approvers only to a caller who may read the
        submission; otherwise it says the submission isn't at their step yet,
        or that it isn't waiting on them. Anyone else receives `403`.

        **Preconditions:**
        - The submission must be **awaiting review** (`submitted` or `under_review`),
          else `422` with code `invalid_state`.
        - Approval is blocked while any field is still returned to the employee for
          field-level correction (`422` with code `open_returns`).

        Sets `status = approved`, stamps `reviewed_by` / `reviewed_at`, and
        optionally records `review_notes`. The submitter is emailed a
        status-changed notification (best-effort).
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                review_notes:
                  type: string
                  description: Optional approval note recorded on the submission.
                  example: Approved — all required documents attached.
      responses:
        '200':
          description: Submission approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Submission approved
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — token lacks `write:forms` scope (`insufficient_permissions`)
            or the caller is not a reviewer (`access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            Submission is not awaiting review (`invalid_state`) or fields are still
            returned to the employee (`open_returns`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/reject":
    post:
      tags:
      - Forms
      summary: Reject a form submission
      description: |
        Reject a submission that is awaiting review, mirroring the web reject
        modal and the native reject-note bottom sheet (swipe-left / "Reject…").

        **Authorization:** the caller must be able to decide this submission —
        the rule the web uses (`Forms::SubmissionAccess#decidable?`): an admin or
        Forms App Admin, one of the form's own people (owner, owning team,
        co-owners, or its creator while inside the Forms Manage tier), the
        submitter's manager, or a person the form's approval workflow names for
        the step the submission is on (`write:forms` scope). A person named on
        another step of its workflow receives `403` with code
        `not_current_approver`. Its message names the step the submission is
        waiting on and that step's approvers only to a caller who may read the
        submission; otherwise it says the submission isn't at their step yet,
        or that it isn't waiting on them. Anyone else receives `403`.

        **Preconditions:**
        - `review_notes` (the rejection reason) is **required**; a missing or
          blank/whitespace-only value returns `422` with code `review_notes_required`.
          The note is shown to the submitter so they can correct and resubmit.
        - The submission must be **awaiting review** (`submitted` or `under_review`),
          else `422` with code `invalid_state`.

        Sets `status = rejected`, stamps `reviewed_by` / `reviewed_at`, and stores
        `review_notes`. The submitter is emailed a status-changed notification
        (best-effort).

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:forms` scope.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                review_notes:
                  type: string
                  description: Reason for rejection (required). Shown to the submitter.
                  example: The incident date is missing — please add it and resubmit.
              required:
              - review_notes
      responses:
        '200':
          description: Submission rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Submission rejected
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — token lacks `write:forms` scope (`insufficient_permissions`)
            or the caller is not a reviewer (`access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            `review_notes` is missing or blank (`review_notes_required`), or the
            submission is not awaiting review (`invalid_state`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/return_fields":
    post:
      tags:
      - Forms
      summary: Return individual fields for correction
      description: |
        The reviewer's **third option**, alongside approve and reject: return
        NAMED fields to the submitter with a per-field comment, instead of
        rejecting a whole submission over a couple of bad answers. The submitter
        then corrects only those fields — everything else stays locked and
        answered.

        Shares its implementation with the web reviewer action
        (`PATCH /apps/forms/submissions/:id/return_fields`) — same returnable-field
        rules, same preconditions, same notifications — so the two surfaces cannot
        drift on what a return does.

        **Authorization:** the caller must be able to decide this submission —
        the rule the web uses (`Forms::SubmissionAccess#decidable?`): an admin or
        Forms App Admin, one of the form's own people (owner, owning team,
        co-owners, or its creator while inside the Forms Manage tier), the
        submitter's manager, or a person the form's approval workflow names for
        the step the submission is on (the token must carry `write:forms`). A
        person named on another step of its workflow receives `403` with code
        `not_current_approver`. Its message names the step the submission is
        waiting on and that step's approvers only to a caller who may read the
        submission; otherwise it says the submission isn't at their step yet,
        or that it isn't waiting on them. Anyone else receives `403`.

        **Preconditions:**
        - The submission must be **awaiting review** (`submitted` or `under_review`),
          else `422` with code `not_awaiting_review`.
        - There must be someone to return it to. An **anonymous** submission
          (public portal) returns `422` with code `anonymous_submission`; one
          sent without an account (a candidate's assessment, a job application)
          `submitter_has_no_account`; a submitter who is no longer an active
          member of the organization `submitter_not_member`; a customer's
          answers to an Implementation Hub project's form request go back
          through the request (it is reopened and the customer told), and once
          no request holds them or the project is closed to the customer the
          call returns `request_closed`, or `submitter_left_team` when the
          person whose copy it is has left the customer's team — approve or
          reject it instead. `can_return_fields` on the submission says which.
        - At least one **returnable** field must carry a **non-blank comment**,
          else `422` with code `no_returnable_fields`.

        **Fields that are never returnable** — dropped server-side, never applied,
        and named back in `ignored_fields`:
        - file / media fields (`file`, `image`, `video`, `audio`, `gallery`) and
          `annotation` — the corrections form cannot re-process an upload
        - any field this reviewer is not permitted to READ (field-level visibility)
        - any name that is not a field on the form
        - any field supplied with a blank comment

        **Effects:** `status` → `changes_requested`, `review_round` incremented by
        1, a `field_reviews` entry written per returned field (state / comment /
        round / history), every other answered field this reviewer may read marked
        `accepted` (named in `accepted_fields`), and `reviewed_by` / `reviewed_at`
        stamped. The submitter
        receives an in-app *action required* notification **and** an email
        (best-effort — a delivery failure never fails the call).

        A subsequent `GET /api/v1/forms/{form_id}?submission_id={id}` reflects the
        new `returned_field_names`, each field's `field_review`, and
        `is_editable: false` on the fields that were not returned.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 4812
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                field_returns:
                  type: object
                  description: |
                    `{ field_name: comment }`. The comment is REQUIRED per field
                    and is shown to the submitter — a blank comment does not
                    return the field.
                  additionalProperties:
                    type: string
                  example:
                    incident_location: Please give the bay number, not just 'warehouse'.
                    witness_name: Left blank — required if anyone else was present.
                review_notes:
                  type: string
                  description: Optional submission-level note recorded on the submission.
                  example: Two fields need detail before I can approve.
              required:
              - field_returns
      responses:
        '200':
          description: Fields returned to the submitter for correction
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Returned 2 answers for correction
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  ignored_fields:
                    type: array
                    description: |
                      Names the request asked to return that the server dropped —
                      non-returnable, unknown, or supplied with a blank comment.
                      **Omitted entirely when nothing was dropped.** Same key and
                      same meaning as on the submission write paths.
                    items:
                      type: string
                    example:
                    - site_photo
                  warning:
                    type: string
                    description: Human-readable companion to `ignored_fields`; omitted
                      when nothing was dropped.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Forbidden — token lacks `write:forms` scope (`insufficient_permissions`)
            or the caller is not a reviewer (`access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            Submission is not awaiting review (`not_awaiting_review`), was submitted
            anonymously (`anonymous_submission`) or without an account
            (`submitter_has_no_account`), its submitter is no longer a member
            (`submitter_not_member`) or no longer on the customer's team
            (`submitter_left_team`), its project's form request is closed
            (`request_closed`), no returnable field carried a
            comment (`no_returnable_fields`, whose `details.ignored_fields` names
            what was dropped), or the return could not be saved (`return_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/withdraw":
    post:
      tags:
      - Forms
      summary: Withdraw a submission waiting for review
      description: |
        The submitter takes back their own submission while it waits for a
        decision; it becomes a draft again to correct and send
        (`MaForms::FormSubmission#withdraw!` — the web's **Withdraw**). The
        reviewers' open asks close. Refused (`422 cannot_withdraw`, with the
        reason as the message and the current `status` in `error.details`)
        when the caller did not send it, it is not waiting for review, an
        approval-workflow step was already signed, it was filed to HR Files,
        it belongs to an onboarding plan item, or its form no longer accepts
        responses. `can_withdraw` / `withdraw_refusal` on the submission say
        which in advance. Requires `write:forms`.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 4812
      responses:
        '200':
          description: Withdrawn — the submission is a draft again
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Submission withdrawn — it is a draft again
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: The submission can't be withdrawn (`cannot_withdraw`) or could
            not be saved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/reopen":
    post:
      tags:
      - Forms
      summary: Reopen an approved submission for review
      description: |
        Takes an approval made by mistake back to **Under review**
        (`MaForms::FormSubmission#reopen_for_review!` — the web's **Reopen for
        review**). `reason` is required; the submitter is told it, and whoever
        may decide the submission now is asked again (not the caller, unless
        nobody else may). Refused (`422 cannot_reopen`, the reason as the
        message) unless the caller may approve it and the approval has had no
        effect reopening cannot undo — a multi-step workflow's sign-offs, a
        document filed to HR Files, a verified onboarding item, or a workflow
        rule for approved submissions that already ran. `can_reopen` /
        `reopen_refusal` on the submission say which in advance. Requires
        `write:forms`.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 4812
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 1000
                  description: Why it is reopened — shown to the submitter.
                  example: Approved the wrong row from the list.
              required:
              - reason
      responses:
        '200':
          description: Reopened — the submission waits for a decision again
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Submission reopened for review
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            The submission can't be reopened (`cannot_reopen`), `reason` is
            missing (`reason_required`), or it could not be saved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/form_submissions/{id}/take_back_returns":
    post:
      tags:
      - Forms
      summary: Take back a return for correction
      description: |
        A reviewer takes back a return for correction
        (`MaForms::FormSubmission#take_back_returns!` — the web's **Take back
        the return**): the submission waits for a decision again with its
        answers as sent, the submitter's "correct these answers" ask closes and
        they are told why, and whoever may decide it now is asked (not the
        caller, unless nobody else may). `reason` is required. Refused
        (`422 cannot_take_back`, the reason as the message) unless it is
        returned for correction and the caller manages it or is the reviewer
        who returned it while still signing its current step — and never on an
        onboarding plan item's form (the new hire corrects it in their
        onboarding plan). `can_take_back_returns` / `take_back_returns_refusal`
        on the submission say which in advance. Requires `write:forms`.
      parameters:
      - name: id
        in: path
        required: true
        description: Form submission ID
        schema:
          type: integer
          example: 4812
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 1000
                  description: Why the return is taken back — shown to the submitter.
                  example: Returned the wrong answers — they were fine.
              required:
              - reason
      responses:
        '200':
          description: Return taken back — the submission is under review again
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Return taken back — the submission is under review again
                  submission:
                    "$ref": "#/components/schemas/ReviewedSubmission"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: |
            The return can't be taken back (`cannot_take_back`), `reason` is
            missing (`reason_required`), or it could not be saved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/marketplace_apps":
    get:
      tags:
      - Marketplace Apps
      summary: List enabled marketplace apps
      description: |
        Returns a list of marketplace apps that are enabled for the authenticated user's business.
        This endpoint provides all data needed to display a grid view of apps in a mobile client,
        including app icons, names, descriptions, and launch URLs.

        Apps are returned in the business's app order (the apps its admin numbered in
        Sort Order first, then the rest by the catalogue's rank), the order the web lists
        them in; each app's `sort_order` is its place in that order, starting at 1. They
        include configuration data specific to the business.

        **Required Scopes:** `read:organization` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made by
        hand before this deploy may not. A browser session is asked no scope.
      parameters:
      - name: include_metadata
        in: query
        required: false
        description: Whether to include app metadata in response
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Enabled marketplace apps retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MarketplaceApp"
                  total_count:
                    type: integer
                    description: Total number of enabled apps
                    example: 5
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/core_apps":
    get:
      tags:
      - System Modules
      summary: List enabled core apps
      description: |
        Returns a list of core platform apps and their enabled status for the authenticated user's business.
        Core apps are fundamental platform features like Shifts & Scheduling, Time & Attendance, etc.
        that can be enabled or disabled at the business level.

        This endpoint provides all data needed to display core app status in a mobile client,
        including app icons, descriptions, feature lists, and direct URLs.

        **Required Scopes:** `read:organization` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made by
        hand before this deploy may not. A browser session is asked no scope.
      responses:
        '200':
          description: Core apps status retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CoreApp"
                  total_count:
                    type: integer
                    description: Total number of core apps
                    example: 6
                  enabled_count:
                    type: integer
                    description: Number of enabled core apps
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
  "/apps":
    get:
      tags:
      - Apps
      summary: List all apps (core and marketplace)
      description: |
        **Consolidated endpoint** that returns both core apps and marketplace apps in a single response.
        This is the recommended endpoint for mobile clients to build the app grid view.

        **Required Scopes:** `read:organization` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made by
        hand before this deploy may not. A browser session is asked no scope.

        Core apps are fundamental platform features (Shifts & Scheduling, Time & Attendance, etc.)
        while marketplace apps are add-on features that can be enabled/disabled.

        **Features:**
        - Single API call instead of multiple requests
        - Unified sorting and filtering across all app types
        - Category-based organization
        - Type filtering (core, marketplace, or all)
        - Enabled/disabled filtering
        - **Mobile native app exclusion** via `exclude_mobile_native` parameter

        **Response includes:**
        - App icons (Font Awesome classes for mobile, with web_url fallback for custom SVG icons)
        - Launch URLs
        - Feature lists
        - Metadata
        - Category grouping

        **Icon Handling for Mobile:**
        All apps now return Font Awesome 5 icon classes in `icon.url` with `icon.type: "icon_class"`.
        For apps that have custom SVG icons, the original SVG path is available in `icon.web_url`
        for web clients that prefer to use the custom icons.

        **Mobile Native Apps:**
        Use `exclude_mobile_native=true` to exclude apps that are natively supported in mobile clients:
        - shift-marketplace
        - shifts_scheduling
        - time_attendance
        - leave_management
        - timesheets
      parameters:
      - name: type
        in: query
        required: false
        description: Filter apps by type (core, marketplace, or all)
        schema:
          type: string
          enum:
          - all
          - core
          - marketplace
          default: all
        example: all
      - name: category
        in: query
        required: false
        description: Filter apps by category (e.g., "HR Management", "Workforce Management")
        schema:
          type: string
        example: Workforce Management
      - name: enabled_only
        in: query
        required: false
        description: Whether to return only enabled apps
        schema:
          type: boolean
          default: true
        example: true
      - name: exclude_mobile_native
        in: query
        required: false
        description: |
          Exclude apps that are natively supported in mobile clients.
          When set to true, the following apps are excluded:
          - shift-marketplace
          - shifts_scheduling
          - time_attendance
          - leave_management
          - timesheets

          This is useful for mobile clients that already have native implementations
          of these core features and only need to display marketplace/web-based apps.
        schema:
          type: boolean
          default: false
        example: false
      - name: include_navigation
        in: query
        required: false
        description: |
          Opt into the **grouped, navigation-aware** response shape. Off by
          default so existing clients keep the flat legacy payload described
          above — this parameter changes the TOP-LEVEL shape of the response,
          it does not merely add a field.

          When `include_navigation=true`:

          * The response is split into `pinned_apps` (the apps pinned for this
            user) and `apps` (the remaining enabled apps), with `pinned_count`
            / `apps_count` / `total_count` / `categories`. `core_count`,
            `marketplace_count` and `enabled_count` are **not** returned in
            this mode.
          * Every app object carries `pinned`, `has_mobile_view`, `mobile_url`
            and a `navigation_items` array — the app's in-app navigation
            (its sidebar tabs / mobile TabBar), so a client can render an app's
            sub-pages without a second call per app.
          * `enabled_only` and `type` are **ignored**: pinned/unpinned lists
            come from the web sidebar's own role-aware source
            (`SidebarHelper#pinned_apps_for_user` / `#unpinned_enabled_apps`),
            which only ever yields enabled, user-accessible marketplace/core
            apps. `category` and `exclude_mobile_native` are still applied.
          * For mobile clients (native `User-Agent`), apps without a
            mobile-optimized view are dropped entirely, and each item's `path`
            is rewritten to its `/m/...` equivalent (see `navigation_items`).

          `navigation_items` is **role-aware and gated per app** — it contains
          only what the caller may actually open, so an item's absence is the
          authorization signal. Two examples of the gating, straight from the
          builders:

          * **Ideas** always returns `dashboard` (`/apps/ideas`) and
            `all_ideas` (`/apps/ideas/list`); `campaigns` appears unless the
            business explicitly disabled campaigns
            (`configuration["campaigns"] == false`); `review_queue`
            ("Reviews") appears **only** for members of a review panel — the
            workspace default panel or a campaign panel. Panel membership is
            the only grant: **admins get no bypass**, matching the web
            "My Review Queue" tab.
          * **Forms** always returns `my_submissions`; `approvals` appears only
            for reviewers (admin / manager / Forms App Admin) and carries a
            live `count` of pending approvals.
          * **Communications** returns a fixed four-item set with no gating:
            `dashboard` (`/apps/communications`), `feed`
            (`/apps/communications/feed`), `mail`
            (`/apps/communications/messages`) and `my_posts`
            (`/apps/communications/my-posts`). There is **no `notifications`
            item** — notifications are a platform surface the client owns and
            reads from `GET /api/v1/notifications`, not a Communications tab.
          * **Training** returns a fixed native-app bottom-tab set: the learner
            tabs `my_learning` ("My Learning", `/m/apps/training`),
            `catalog` ("Catalog", `/m/apps/training/catalog`) and
            `my_records` ("My Records", `/m/apps/training/certificates`)
            are always present; `my_team` ("My Team",
            `/m/apps/training/manager`) appears **only** for people-managers and
            admins/HR admins. The `my_learning` title is TENANT-RENAMEABLE —
            it is the Training app's `portal_title` setting, falling back to
            "My Learning" when the tenant has not set one, so it always matches
            the heading of the page the tab opens. Learning Paths is a course
            *type* inside Catalog, not a tab, so it is not a navigation item.
          * **Recognitions** always returns `dashboard` (`/recognition`),
            `feed` (`/recognition/feed`), `my_recognition`
            (`/recognition/my_recognition`) and `leaderboard`
            (`/recognition/leaderboard`), in that order. `programs`
            (`/recognition/programs`) appears unless the business turned award
            requests off (`enable_award_requests == false`); `awards`
            ("Award Cycles", `/recognition/award-cycles`) appears **only** when
            the business turned award cycles on (`enable_award_cycles == true`,
            off by default); `team` (`/recognition/manager`) appears **only**
            for recognition reviewers — business admins, Recognitions app
            admins, and anyone with direct reports. The app's admin surfaces
            (Analytics / Admin / Settings on the web rail) are deliberately not
            part of this mobile navigation, and `team` carries no badge count.
          * **Company Store** returns the native-app bottom-tab set:
            `dashboard` ("Dashboard", `/apps/company-store`), `catalog`
            ("Catalog", `/apps/company-store/catalog`), `orders` ("Orders",
            `/apps/company-store/orders`) and `balance` ("Points",
            `/apps/company-store/balance`) are always present, in that order;
            `approvals` ("Approvals",
            `/apps/company-store/manager/approvals`) appears **only** for
            redemption approvers — a member of the tenant's designated
            redemption-approver group, or a manager with direct reports when no
            such group is configured (a configured group supersedes the org
            chart, so a line manager outside it is not an approver; admins with
            direct reports are exempt from that supersession). A business admin
            with **no** direct reports is not an approver here — admins act on
            held orders through the admin orders queue instead. The rail's Cart
            tab (which appears only once a cart exists), its Team Budget action
            and its Admin / Settings dropdowns are not part of this mobile
            navigation, and `approvals` carries no badge count.
          * **Frontline Execution** returns a CURATED SUBSET of the app's web
            LHS sidebar — six tabs, each shown under the SAME visibility gating
            it has on the web rail: if the tab is hidden on web for this caller
            it is withheld here too. Two things gate a tab — its tenant SURFACE
            TOGGLE (so a disabled surface never leaves a tab that 403s) and, for
            the author-tier tabs, the caller's role. The tabs, in web order:

              * `my_day` ("My Day", `/apps/frontline-execution/my-day`) — every
                user; requires `enable_my_day`.
              * `requests` ("My requests",
                `/apps/frontline-execution/requests`) — every user; requires
                `enable_campaigns`.
              * `day_sheet` ("Day Sheet",
                `/apps/frontline-execution/day-sheet`) — the AUTHOR TIER (a
                business admin, the Frontline Execution app admin, or any
                manager); requires `enable_my_day`.
              * `reviews` ("Reviews", `/apps/frontline-execution/reviews`) —
                the author tier OR a completion **reviewer**; requires
                `enable_my_day`.
              * `campaigns` ("Campaigns",
                `/apps/frontline-execution/campaigns`) — the author tier;
                requires `enable_campaigns`.
              * `coverage` ("Coverage", `/apps/frontline-execution/coverage`) —
                the author tier; requires `enable_coverage`.

            `my_day` shows for every frontline user with no role gate (its
            endpoints — `GET /api/v1/frontline_execution/my_day` and
            `.../my_day/everything` — carry no author-tier gate either), so head
            office / district / regional users reach their own daily surface too.
            Paths are the web rail's own desktop `/apps/...` paths. No item
            carries a badge count. The rest of the web rail — Overview, Request
            queue, Calendar, People, Shared blockers, Analytics and the Admin
            zone (Settings / Import / Workforce sources) — is not part of this
            bar.
          * **Safety Hub** returns the per-persona navigation from the Safety
            Hub mobile prototype, in two zones. The **My** zone is every user's:
            `safety_hub_my_submitted` ("My Submitted",
            `/apps/safety-hub/submitted_by_me`) appears when either reporting
            module (incidents / observations) is on; `safety_hub_my_alerts`
            ("My Alerts", `/apps/safety-hub/alerts/my`) follows the
            `emergency_alerts_enabled` toggle; `safety_hub_my_permits`
            ("My Permits", `/apps/safety-hub/permits`, gated on
            `permits_enabled`) and `safety_hub_my_corrective_actions`
            ("My Corrective Actions", `/apps/safety-hub/corrective_actions`,
            gated on `incidents_enabled`) appear **only for non-managers** (a
            manager reaches the whole board from the Team zone, so emitting them
            too would be a second row to the same path); `safety_hub_knowledge_base`
            ("Knowledge Base", `/apps/safety-hub/knowledge_base`) is always
            present. The **Team** zone appears **only** for managers — a
            `manager_or_above?` user or the Safety Hub app admin — and holds
            `safety_hub_team_alerts` ("Team Alerts", `/apps/safety-hub/alerts`),
            `safety_hub_team_incidents` ("Team Incidents",
            `/apps/safety-hub/incidents`), `safety_hub_team_observations`
            ("Team Observations", `/apps/safety-hub/safety_observations`),
            `safety_hub_team_permits` ("Team Permits", `/apps/safety-hub/permits`,
            gated on `permits_enabled` — the whole permit board, the same index
            My Permits narrows for a non-manager) and
            `safety_hub_team_corrective_actions` ("Team Corrective Actions",
            `/apps/safety-hub/corrective_actions`), each gated by its module
            toggle. Keys are namespaced `safety_hub_*`; the app's compliance,
            certifications, toolbox-talks, campaigns and admin surfaces are not
            part of this navigation, and no item carries a badge count.
          * **Service Desk** returns the per-persona bottom bar from the
            Service Desk mobile prototype. The persona is the caller's Service
            Desk role: **admin** (business admin or Service Desk app admin),
            **agent** (member of an active support team, or granted agent
            rights in Settings → Agents), **manager** (help
            desk manager without agent rights) or **member** (everyone else).

              * **member** — `service_desk_my_requests` ("My Requests"),
                `service_desk_grievances` ("Grievances",
                `/apps/service-desk/grievances`) and
                `service_desk_knowledge_base` ("Knowledge",
                `/apps/service-desk/knowledge-base`).
              * **manager** — `service_desk_my_requests`,
                `service_desk_access_requests` ("Access",
                `/apps/service-desk/access-requests`),
                `service_desk_grievances` and `service_desk_knowledge_base`.
              * **agent** and **admin** — `service_desk_agent_dashboard`
                ("Dashboard", `/apps/service-desk/agent_dashboard`),
                `service_desk_tickets` ("Tickets" — an agent's own workbench
                `/apps/service-desk/agent_tickets`; for an admin the
                tenant-wide `/apps/service-desk/tickets`),
                `service_desk_knowledge_base`, and `service_desk_more` ("More").
                `service_desk_more` has no `path`; its `actions` list the
                enabled overflow modules — `service_desk_problems`
                (`/apps/service-desk/problems`), `service_desk_changes`
                (`/apps/service-desk/changes`), `service_desk_access_requests`
                and `service_desk_grievances` — and each action carries its own
                `key`. When only one of them is enabled it is returned as a
                normal item instead of `service_desk_more`; when none is, there
                is no More item.

            `service_desk_my_requests` is `/m/apps/service-desk` for a mobile
            caller and `/apps/service-desk/my_requests` otherwise. Knowledge
            follows the tenant's `knowledge_base_enabled` setting; Access,
            Problems, Changes and Grievances follow their Service Desk modules.
            `service_desk_grievances` is the app's door to file a grievance
            (GET /service_desk/service_catalog never lists that type); render
            it from GET /service_desk/grievances. Keys are namespaced
            `service_desk_*`. Analytics, Settings and CMDB are not part of this
            navigation, and no item carries a badge count.
          * **Sites** returns the bar from the Sites mobile prototype — the web
            rail's reader rows, in its order, each under the SAME gate the web
            rail applies (asked of the web's own Sites predicates, so the two
            cannot disagree about a person):

              * `sites_dashboard` ("Dashboard", `/apps/sites`, icon
                `fas fa-gauge`) — only when the Dashboard has work for the
                caller: a business admin, a Sites app admin, someone who may
                create sites or owns / edits one, a page reviewer, or the owner
                of a page. A member who only reads has no Dashboard (on the web
                they land on All sites).
              * `sites_all_sites` ("All sites", `/apps/sites/list`,
                `fas fa-globe`) — always.
              * `sites_recent` ("Recent", `/apps/sites/recent`,
                `fas fa-clock-rotate-left`) — while the tenant records page
                views (`enable_analytics`, on by default).
              * `sites_saved` ("Saved & following", `/apps/sites/bookmarks`,
                `fas fa-bookmark`) — always, with the same label for every
                role. `count` is the number of must-read pages waiting on the
                caller's confirmation; while it is above 0 the path opens on
                that list (`/apps/sites/bookmarks?tab=confirm`). `tabs` is the
                screen's segmented control, the web's tabs in order — `{ key,
                title }`, `count` on `confirm` — each key naming its list:
                `bookmarks` → `GET /sites/bookmarks`, `following` → `GET
                /sites/following`, `watching` → `GET /sites/watching`,
                `confirm` → `GET /sites/to_confirm`. Following and Watching
                are there only while the tenant's site follows are on.
              * `sites_approvals` ("Approvals", `/apps/sites/reviews`,
                `fas fa-clipboard-check`) — only while page approvals are on
                (`enable_content_review`) and the caller is a business admin, a
                Sites app admin, an owner or editor of a site, or someone a
                page approval was sent to or sent by. `count` is what waits on
                the caller (requests to decide, their requests sent back, and
                their approved pages ready to publish); while it is above 0 the
                path opens on that list (`/apps/sites/reviews?filter=waiting`).

            For a mobile caller the paths are the phone pages:
            `sites_dashboard` and `sites_all_sites` → `/m/apps/sites` (the
            phone sites list, which draws the Dashboard's sections above it),
            `sites_recent` → `/m/apps/sites/recent`, `sites_saved` →
            `/m/apps/sites/bookmarks` (`?tab=confirm` as above).
            `sites_approvals` has no phone page and keeps its desktop path —
            draw it natively by `key`. Keys are namespaced `sites_*`. The web
            rail's "Your sites", Site resources and Admin groups are not part
            of this navigation. Titles are written in the caller's language,
            as for Service Desk.
          * **Meal Compliance** returns the per-persona bar from the Meal
            Compliance mobile prototype, each item under the SAME gate the web
            rail applies (asked of the web's own Meal Compliance predicates):

              * `meal_compliance_dashboard` ("Dashboard",
                `/apps/meal-compliance`, icon `fas fa-gauge-high`) — managers
                and admins: a business admin, a Meal Compliance app admin, an
                HR admin, or a manager of direct reports (a manager role, or
                anyone people report to).
              * `meal_compliance_my_breaks` ("My Breaks",
                `/apps/meal-compliance/my-breaks`, `fas fa-mug-hot`) —
                always: the caller's own waiver status and break history.
              * `meal_compliance_waivers` ("Waivers",
                `/apps/meal-compliance/waivers`, `fas fa-file-signature`) —
                the same people as Dashboard.
              * `meal_compliance_break_compliance` ("Break Compliance",
                `/apps/meal-compliance/break-compliance`,
                `fas fa-clipboard-check`) — admins only: a business admin, a
                Meal Compliance app admin or an HR admin.

            So an employee gets My Breaks; a manager Dashboard · My Breaks ·
            Waivers; an admin all four, in that order. The paths are the
            desktop pages for every caller, mobile included — draw each screen
            natively by `key` (the desktop My Breaks page sends a phone to
            `/m/apps/meal-compliance` itself). An employee's single item is
            kept, not collapsed into the tile. Keys are namespaced
            `meal_compliance_*`. The web rail's Settings is not part of this
            navigation, and no item carries a badge count. Titles are written
            in the caller's language, as for Service Desk.

            Every item also carries `can_create_waiver` (boolean, the same
            value on each item): whether the caller may start a New Waiver,
            which the prototype offers as the (+) button on Dashboard and
            Waivers. It is the web's New Waiver rule — a business admin, a
            Meal Compliance app admin, an HR admin or a manager of direct
            reports — and the token must hold `write:meal_compliance`, so a
            read-only token answers `false` while the items stay the same. It
            matches `permissions.can_create_waiver` on
            `GET /meal_compliance/dashboard` and `GET /meal_compliance/waivers`.
          * **SOP Hub** returns the per-persona bar from the SOP Hub mobile
            prototype, each item under the SAME gate the web rail applies:

              * `sop_hub_dashboard` ("Dashboard", `/apps/sop-hub`, icon
                `fas fa-gauge`) — always. Render it from
                `GET /sop_hub/dashboard`.
              * `sop_hub_my_sops` ("My SOPs",
                `/apps/sop-hub/completions/my_pending`,
                `fas fa-clipboard-check`) — always: the caller's own SOPs,
                Pending (`GET /sop_hub/completions/my_pending`) and History
                (`GET /sop_hub/completions/my_history`).
              * `sop_hub_reports` ("Reports",
                `/apps/sop-hub/completions/report`, `fas fa-clipboard-list`) —
                SOP Hub authors only: an HR admin, a business admin, a SOP Hub
                app admin, or a SOP Hub admin limited to places and
                departments — exactly the people `GET /sop_hub/reports/compliance`,
                `GET /sop_hub/completions` and `GET /sop_hub/activity_logs`
                admit, so the tab never opens on a 403.

            So an employee (and a manager who is not a SOP Hub author) gets
            Dashboard · My SOPs; an admin all three, in that order. For a mobile
            caller `sop_hub_dashboard` is `/m/apps/sop-hub` and
            `sop_hub_my_sops` is `/m/apps/sop-hub/my-pending`;
            `sop_hub_reports` has no phone page and keeps its desktop path —
            draw it natively by `key`. Keys are namespaced `sop_hub_*`. The web
            rail's Browse SOPs, Manage, Templates, Manuals and Settings are not
            part of this navigation, and no item carries a badge count. Titles
            are written in the caller's language, as for Service Desk.

          Gotchas:

          * An app can legitimately return `navigation_items: []` — either it
            exposes no sub-navigation, or (on mobile) every tab it has was
            dropped for lacking a real `/m/` route. Build the tile to open
            `mobile_url` / `url` in that case.
          * Single-child collapse (mobile only): when an app would return
            exactly ONE navigable item that belongs to its own mobile route,
            the item is removed and `mobile_url` is repointed at it, so the
            tile opens that page directly instead of a one-row submenu.
          * `navigation_items` is best-effort per app: if the underlying tab
            builder raises, that app returns `[]` rather than failing the
            request.
          * An item's `title` (and an action's `title`) is display text, not an
            identifier — clients branch on `key`. For **Service Desk**,
            **Sites** and **Meal Compliance** the titles are written in the language the client asks for: the
            `locale` query parameter, else `Accept-Language` (first supported
            tag by q-value; `nb`/`nn` read as `no`, `iw` as `he`), else the
            caller's own profile language, else the tenant's default, else
            English — whether or not the tenant translates its web pages. The
            response then carries `Vary: Accept-Language`. Other apps' titles
            are English today.
        schema:
          type: boolean
          default: false
        example: true
      - name: locale
        in: query
        required: false
        description: |
          The language the client's own interface is in (e.g. `fr`, `pt-BR`).
          Takes precedence over `Accept-Language` for the `title`s written in
          the caller's language (see `include_navigation`). An unsupported
          code is ignored.
        schema:
          type: string
        example: fr
      responses:
        '200':
          description: Apps retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  apps:
                    type: array
                    description: |
                      Array of apps (both core and marketplace).

                      With `include_navigation=true` this holds only the
                      **unpinned** enabled apps (the pinned ones move to
                      `pinned_apps`), and every entry carries `pinned`,
                      `has_mobile_view`, `mobile_url` and `navigation_items`.
                    items:
                      "$ref": "#/components/schemas/ConsolidatedApp"
                  pinned_apps:
                    type: array
                    description: |
                      The apps pinned for this user, in the same order the web
                      sidebar pins them. **Only present when
                      `include_navigation=true`.** Same item schema as `apps`,
                      with `pinned: true`.
                    items:
                      "$ref": "#/components/schemas/ConsolidatedApp"
                  pinned_count:
                    type: integer
                    description: Number of entries in `pinned_apps`. Only present
                      when `include_navigation=true`.
                    example: 4
                  apps_count:
                    type: integer
                    description: Number of entries in `apps` (unpinned). Only present
                      when `include_navigation=true`.
                    example: 18
                  total_count:
                    type: integer
                    description: |
                      Total number of apps returned. With
                      `include_navigation=true` this is
                      `pinned_count + apps_count`.
                    example: 12
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  core_count:
                    type: integer
                    description: Number of core apps in response
                    example: 6
                  marketplace_count:
                    type: integer
                    description: Number of marketplace apps in response
                    example: 6
                  enabled_count:
                    type: integer
                    description: Number of enabled apps
                    example: 9
                  categories:
                    type: array
                    description: List of unique categories across all apps
                    items:
                      type: string
                    example:
                    - HR Management
                    - Workforce Management
                    - Analytics
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
              examples:
                default:
                  summary: All apps (default)
                  value:
                    apps:
                    - id: shifts_scheduling
                      slug: shifts_scheduling
                      name: Shifts & Scheduling
                      description: Complete shift scheduling and management system
                      type: core
                      category: Workforce Management
                      enabled: true
                      icon:
                        url: fas fa-calendar-alt
                        type: icon_class
                      url: "/shifts"
                      sort_order: 1
                    - id: 123
                      slug: employee-performance-management
                      name: Employee Performance Management
                      description: Comprehensive performance management system
                      type: marketplace
                      category: performance
                      enabled: true
                      icon:
                        url: fas fa-chart-line
                        type: icon_class
                        web_url: "/assets/icons/employee-performance-management.svg"
                      url: "/apps/employee-performance-management"
                      sort_order: 2
                    total_count: 12
                    core_count: 6
                    marketplace_count: 6
                    enabled_count: 10
                    categories:
                    - HR Management
                    - Workforce Management
                    - performance
                core_only:
                  summary: Core apps only
                  value:
                    apps:
                    - id: shifts_scheduling
                      slug: shifts_scheduling
                      name: Shifts & Scheduling
                      type: core
                      enabled: true
                      icon:
                        url: fas fa-calendar-alt
                        type: icon_class
                    total_count: 6
                    core_count: 6
                    marketplace_count: 0
                    enabled_count: 6
                marketplace_only:
                  summary: Marketplace apps only
                  value:
                    apps:
                    - id: 123
                      slug: employee-performance-management
                      name: Employee Performance Management
                      type: marketplace
                      enabled: true
                      icon:
                        url: fas fa-chart-line
                        type: icon_class
                        web_url: "/assets/icons/employee-performance-management.svg"
                    total_count: 6
                    core_count: 0
                    marketplace_count: 6
                    enabled_count: 4
                exclude_mobile_native:
                  summary: Exclude mobile native apps
                  description: Response when exclude_mobile_native=true, excluding
                    shifts_scheduling, time_attendance, leave_management, timesheets
                  value:
                    apps:
                    - id: skills_certifications
                      slug: skills_certifications
                      name: Skills & Certifications
                      type: core
                      category: HR Management
                      enabled: true
                      icon:
                        url: fas fa-graduation-cap
                        type: icon_class
                    - id: compensation_management
                      slug: compensation_management
                      name: Compensation Management
                      type: core
                      category: HR Management
                      enabled: true
                      icon:
                        url: fas fa-dollar-sign
                        type: icon_class
                    - id: 123
                      slug: employee-performance-management
                      name: Employee Performance Management
                      type: marketplace
                      enabled: true
                      icon:
                        url: fas fa-chart-line
                        type: icon_class
                        web_url: "/assets/icons/employee-performance-management.svg"
                    total_count: 8
                    core_count: 2
                    marketplace_count: 6
                    enabled_count: 7
                    categories:
                    - HR Management
                    - performance
                include_navigation:
                  summary: Grouped format with per-app navigation (include_navigation=true)
                  description: |
                    Pinned/unpinned grouping with role-aware `navigation_items`.
                    This Ideas entry is what a NON-reviewer receives — the
                    `review_queue` ("Reviews") item is absent because the caller
                    belongs to no review panel.
                  value:
                    pinned_apps:
                    - id: 141
                      slug: ideas
                      name: Ideas
                      type: marketplace
                      category: Collaboration
                      enabled: true
                      pinned: true
                      icon:
                        url: fas fa-lightbulb
                        type: icon_class
                      url: "/apps/ideas"
                      has_mobile_view: true
                      mobile_url: "/m/apps/ideas"
                      navigation_items:
                      - key: dashboard
                        title: Dashboard
                        icon: fas fa-gauge
                        path: "/apps/ideas"
                      - key: all_ideas
                        title: Ideas
                        icon: fas fa-lightbulb
                        path: "/apps/ideas/list"
                      - key: campaigns
                        title: Campaigns
                        icon: fas fa-bullhorn
                        path: "/apps/ideas/campaigns"
                    apps:
                    - id: 152
                      slug: wikis
                      name: Wikis
                      type: marketplace
                      category: Knowledge
                      enabled: true
                      pinned: false
                      icon:
                        url: fas fa-book
                        type: icon_class
                      url: "/apps/wikis"
                      has_mobile_view: true
                      mobile_url: "/m/apps/wikis"
                      navigation_items:
                      - key: dashboard
                        title: Dashboard
                        icon: fas fa-gauge
                        path: "/apps/wikis"
                      - key: all_wikis
                        title: All wikis
                        icon: fas fa-list-ul
                        path: "/apps/wikis/browse"
                    - id: 168
                      slug: safety-hub
                      name: Safety Hub
                      type: marketplace
                      category: workplace-ops
                      enabled: true
                      pinned: false
                      icon:
                        url: fas fa-shield-halved
                        type: icon_class
                      url: "/apps/safety-hub"
                      has_mobile_view: true
                      mobile_url: "/m/apps/safety-hub"
                      navigation_items:
                      - key: safety_hub_my_submitted
                        title: My Submitted
                        icon: fas fa-clipboard-check
                        path: "/apps/safety-hub/submitted_by_me"
                      - key: safety_hub_my_alerts
                        title: My Alerts
                        icon: fas fa-bullhorn
                        path: "/apps/safety-hub/alerts/my"
                      - key: safety_hub_knowledge_base
                        title: Knowledge Base
                        icon: fas fa-book-open
                        path: "/apps/safety-hub/knowledge_base"
                      - key: safety_hub_team_alerts
                        title: Team Alerts
                        icon: fas fa-bullhorn
                        path: "/apps/safety-hub/alerts"
                      - key: safety_hub_team_incidents
                        title: Team Incidents
                        icon: fas fa-triangle-exclamation
                        path: "/apps/safety-hub/incidents"
                      - key: safety_hub_team_observations
                        title: Team Observations
                        icon: fas fa-eye
                        path: "/apps/safety-hub/safety_observations"
                      - key: safety_hub_team_permits
                        title: Team Permits
                        icon: far fa-file-lines
                        path: "/apps/safety-hub/permits"
                      - key: safety_hub_team_corrective_actions
                        title: Team Corrective Actions
                        icon: fas fa-clipboard-check
                        path: "/apps/safety-hub/corrective_actions"
                    - id: 107
                      slug: sites
                      name: Sites
                      type: core
                      category: productivity-utility
                      enabled: true
                      pinned: false
                      icon:
                        url: fas fa-globe
                        type: icon_class
                      url: "/apps/sites"
                      has_mobile_view: true
                      mobile_url: "/m/apps/sites"
                      navigation_items:
                      - key: sites_dashboard
                        title: Dashboard
                        icon: fas fa-gauge
                        path: "/apps/sites"
                      - key: sites_all_sites
                        title: All sites
                        icon: fas fa-globe
                        path: "/apps/sites/list"
                      - key: sites_recent
                        title: Recent
                        icon: fas fa-clock-rotate-left
                        path: "/apps/sites/recent"
                      - key: sites_saved
                        title: Saved & following
                        icon: fas fa-bookmark
                        path: "/apps/sites/bookmarks?tab=confirm"
                        count: 2
                        tabs:
                        - key: bookmarks
                          title: Bookmarks
                        - key: following
                          title: Following
                        - key: watching
                          title: Watching
                        - key: confirm
                          title: To confirm
                          count: 2
                      - key: sites_approvals
                        title: Approvals
                        icon: fas fa-clipboard-check
                        path: "/apps/sites/reviews?filter=waiting"
                        count: 1
                    total_count: 4
                    pinned_count: 1
                    apps_count: 3
                    categories:
                    - Collaboration
                    - Knowledge
                    - productivity-utility
                    - workplace-ops
                    unread_notification_count: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '422':
          description: Business context not available
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: validation_failed
                      message:
                        type: string
                        example: No business context available
                  apps:
                    type: array
                    items: {}
                    example: []
                  total_count:
                    type: integer
                    example: 0
  "/shifts":
    get:
      tags:
      - Shifts
      summary: List shifts
      description: |
        Returns shifts with flexible filtering and parameter-based querying. Supports:
        - User-specific shifts (user_id=current for authenticated user)
        - Open shifts available for claiming (type=open)
        - Current active shifts (type=current)
        - Period-based filtering (today, week, upcoming, past)
        - **NEW: Marketplace filters**
          - `marketplace_available`: Shifts available in the marketplace that the user can claim
          - `marketplace_claimed`: Shifts the user has claimed from the marketplace
          - `marketplace_listed`: Shifts the user has listed in the marketplace
      parameters:
      - name: type
        in: query
        description: |
          Type of shifts to retrieve.

          **Standard Types:**
          - `assigned`: User's assigned shifts (default)
          - `open`: Open shifts available for claiming
          - `current`: Currently active shifts
          - `all`: All shifts (admin/manager view)

          **Marketplace Types:**
          - `marketplace_available`: Shifts in marketplace available for claiming (excludes user's own shifts)
          - `marketplace_claimed`: Shifts user has claimed from marketplace (identified by marketplace_pickup flag)
          - `marketplace_listed`: Shifts user has listed for others to claim
        schema:
          type: string
          enum:
          - assigned
          - open
          - current
          - all
          - marketplace_available
          - marketplace_claimed
          - marketplace_listed
          default: assigned
      - name: period
        in: query
        description: |
          Time period filter, evaluated against the shift's start/end time.

          `week` uses the TENANT'S configured week start (Settings → business
          hours), not a fixed Monday, so it matches what the web schedule shows.

          On the default shift list, explicit `start_date` / `end_date` / `date`
          take precedence: when any of them is sent, `period` does not narrow
          the result and only sets the sort direction (`past` = newest first).

          Supported with the default shift list, `type=assigned`,
          `type=marketplace_claimed` and `type=marketplace_listed`.
          Combining it with `type=open`, `type=current` or
          `type=marketplace_available` returns 400
          `period_status_filter_not_supported_for_type` — those views already
          return a fixed set (claimable upcoming shifts, or the shifts in
          progress right now) and silently ignoring the filter would ship a
          list the caller believes was narrowed.
        schema:
          type: string
          enum:
          - current
          - today
          - week
          - upcoming
          - past
      - name: user_id
        in: query
        description: User ID or 'current' for authenticated user's shifts
        schema:
          oneOf:
          - type: integer
          - type: string
            enum:
            - current
      - name: location_id
        in: query
        description: |
          Filter by specific location ID. For multiple locations, use location_ids[] instead.
          Also accepts comma-separated values (e.g., location_id=1,2,3) for backwards compatibility.
        schema:
          type: string
        example: '1'
      - name: location_ids[]
        in: query
        description: "Filter by multiple location IDs. Use this to fetch shifts across
          all locations \nassigned to a user. Can be passed as an array (location_ids[]=1&location_ids[]=2)
          \nor comma-separated (location_ids=1,2,3).\n"
        schema:
          type: array
          items:
            type: integer
        style: form
        explode: true
        example:
        - 1
        - 2
        - 3
      - name: scope
        in: query
        description: |
          Narrow the list to shifts belonging to the CALLING user's own
          locations, departments, or teams. Takes no ids — the scope is
          resolved server-side from the authenticated user, so a client never
          has to hold a synced copy of their memberships.

          A shift is in scope when EITHER
            (a) the shift carries one of the USER'S OWN ids for the dimension
                — it is at one of their locations, or it (or its SCHEDULE) is
                for one of their teams / departments, OR
            (b) the shift carries THAT DIMENSION AT ALL, and somebody
                assigned to it is one of the user's people.

          Both halves matter: (a) alone misses a shift your employee picked
          up at another site; (b) alone misses an UNASSIGNED shift at your
          own site, which has nobody on it to make it yours.

          The dimension gate on (b) is what makes the scope name mean what it
          says. `scope=team` returns TEAM shifts only: a shift grouped by
          LOCATION, or an ad-hoc shift with no grouping at all, is not
          returned just because a teammate happens to be rostered on it.
          Likewise `scope=department` returns only shifts that carry a
          department. The gate is a no-op for `scope=location`, where no
          shift can lack a location.

          `scope=team` means CUSTOM scheduling teams. The three group types
          each answer to their own scope name: `location` groups (auto-created
          per site) to `scope=location`, `department` groups (auto-created
          from an org-chart department) to `scope=department`, and `custom`
          groups to `scope=team`. Note a team built by hand and filtered to a
          department is stored as `custom`, so it answers to `scope=team`.

          Composes with `location_id` / `start_date` etc. rather than
          replacing them — every filter narrows. A user whose scope resolves
          to nothing receives an empty list, never the whole business.

          AVAILABLE TO EVERY ROLE, INCLUDING A PLAIN EMPLOYEE (2026-09-02).
          On the DEFAULT list a caller who is not entitled to a business-wide
          read normally receives only their own assigned shifts; passing a
          resolved `scope` lifts that to their own unit — their colleagues'
          shifts, plus unassigned shifts at the unit, plus a colleague's shift
          worked at another site (half (b)). This is parity with the web Team
          Calendar, which has served an employee the same roster since
          2026-07-09.

          The reach is the UNIT and never the tenant: the resolver bounds an
          employee's `location` dimension to their own assigned locations, and
          `team` / `department` to memberships they actually hold.

          TWO THINGS IT DOES NOT LIFT.
            * A token carrying only `read:own_shifts` is unaffected — it still
              receives own-assignment rows with or without `scope`. The
              widening requires `read:shifts` / `write:shifts` / `admin`, or a
              session, exactly as a business-wide read does.
            * `user_id=<someone else>` is still refused with 403. Reading your
              unit's week is not the same as reading one named colleague's
              schedule.

          Team and department are read from the shift's SCHEDULE as well as
          the shift's own columns, because tenants attach them to the
          schedule and only some of it is copied down. `scope=department`
          additionally matches department-flavoured scheduling groups, which
          is the only way an ORG-CHART department resolves at all — neither
          shifts nor schedules carry a column for one.

          Supported with the DEFAULT shift list and `type=open` only.
          Combining it with `type=assigned|current|marketplace_*`, or with
          `user_id=current`, returns 400 `scope_not_supported_for_type` —
          those views are already narrowed to the caller, and silently
          ignoring the filter would return a response whose
          `meta.user_scope` named a scope that was never applied.

          Responses include `meta.user_scope` describing what the scope
          resolved to. A caller that resolves to no locations, teams or
          departments receives an EMPTY list — the filter fails closed.
        schema:
          type: string
          enum:
          - location
          - department
          - team
        example: location
      - name: status
        in: query
        description: |
          Filter by shift status.

          Supported with the same types as `period` (see above); `type=open`,
          `type=current` and `type=marketplace_available` return 400
          `period_status_filter_not_supported_for_type`. On
          `type=marketplace_listed` this filters the LISTING status, not the
          shift status.

          NOTE: `open` is NOT a valid shift status — the model permits only
          scheduled / completed / cancelled / late_reported / absence_reported,
          so `?status=open` matches zero rows. Use `?type=open` to list shifts
          available for claiming.
        schema:
          type: string
          enum:
          - scheduled
          - completed
          - cancelled
          - late_reported
          - absence_reported
      - name: date
        in: query
        description: Filter shifts by specific date (YYYY-MM-DD)
        schema:
          type: string
          format: date
      - name: start_date
        in: query
        description: 'Lower bound on the shift''s START date (inclusive).

          '
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: |
          Upper bound on the shift's START date (inclusive) — i.e. `start_date`
          and `end_date` together select the shifts that BEGIN inside the
          window, which is what the web date filter does.

          This bound was previously applied to `end_time`, which silently
          excluded every open-ended (ad-hoc) shift — those have no end time —
          and every overnight shift that began inside the window but finished
          after it.
        schema:
          type: string
          format: date
      - name: include
        in: query
        description: |
          Additional data to include. Accepts a comma-separated string
          (`include=a,b`) or repeated bracket notation (`include[]=a&include[]=b`).

          `user_scope` adds a `meta.user_scope` object listing the caller's
          own locations, departments and teams. It is included automatically
          whenever `scope` is passed; request it explicitly to read the
          memberships WITHOUT filtering by them.

          `user_scope.departments` names the caller's DEPARTMENT TEAMS —
          scheduling groups of `group_type: "department"`, each rendered as
          `{ id, name, type: "department_team", group_type,
          organizational_department_id }`. These are the same objects a
          matched shift echoes in its own `scheduling_group`, so a client can
          reconcile the two by id. Use `organizational_department_id` to reach
          the org-chart department the team was derived from.

          CAVEAT: `scope=department` ALSO matches shifts on
          `shifts.location_department_id` / `schedule_locations.location_department_id`,
          and those matches have no entry in `user_scope.departments` — a
          shift can therefore be returned with no meta row explaining it, and
          a caller whose only department signal is a LocationDepartment sees
          `departments: []` alongside a non-empty shift list. Prior to
          2026-08-27 this array carried LocationDepartment and
          OrganizationalDepartment records instead; it was changed so the meta
          names the same objects the items do.

          Costs ~13 extra queries
          (measured), and is paid on EVERY page, so it is off by default for
          clients that page this endpoint in a loop.

          `scope_users` adds `meta.user_scope.user_ids` — the people the scope
          resolved to, i.e. the users who share the requested dimension with
          the caller. It answers "who are my colleagues for this scope", which
          is NOT the same question as `teammates`:

            teammates    the people assigned to each returned shift
            user_ids     everyone in my location / team / department, whether
                         or not they have a shift in this response

          So a shift can be returned for a person who is NOT in `user_ids`
          (it is at your location, but they are not one of your people), and
          `user_ids` can name someone with no shift in the response at all.

          Costs no extra queries on a `?scope=`d request — the filter already
          resolved that list to build its roster half.

          IDS ONLY, deliberately: hydrating names and avatars costs roughly
          two queries per user, so fetch the people themselves from
          `/api/v1/users` when you need more than an id.

          The list names the caller's colleagues when the RESPONSE ROWS are
          roster-wide, and is bounded to the caller's own id otherwise
          (`counts.users` then 1, or 0 when the scope resolved to nothing).
          Rows are roster-wide for a business-wide read, and — since
          2026-09-02 — for any role that passed a resolved `scope` on a
          credential permitted to read shifts.

          So the bound still applies to:
            * a caller on the DEFAULT list with no `scope`, whose rows are
              their own assignments, and
            * a caller of ANY role, manager and admin included, whose token
              carries only `read:own_shifts`.

          Unchanged property: the bound never depends on `type`. One resolver
          is memoized per request and shared by every handler, so `user_ids`
          is a function of the caller's entitlement and of whether a `scope`
          was passed — not of which `type` view ran. That matters for
          `type=open`, whose items are unassigned shifts rather than the
          caller's own.

          IT IS THE ROSTER, NOT AN INDEX OF ASSIGNEES. The list is "my
          people" — the members of the resolved unit. A shift returned by
          half (a) may be worked by somebody who does NOT belong to that unit
          (an outsider covering a shift at your site); their shift is in
          `items` and their id is deliberately NOT here. Read an assignee from
          the shift row or `include=teammates`, never by assuming this list
          covers every person you will encounter.

          Capped at 50 like the other `user_scope` arrays, with
          `counts.users` always EXACT and the shared `truncated` flag set when
          the array was cut. For an ADMIN on `scope=location` the list is
          tenant-sized, because an admin's location scope is every location in
          the business — read `counts.users` rather than the array length.
          For `scope=team` and `scope=department` it is membership-only and
          never role-widened. With no `scope`, `user_ids` is empty by
          definition.

          `attendance` REQUIRES `format=detailed` ON THIS ENDPOINT — verified
          over HTTP 2026-09-02. Each item's `attendance_records` array is
          emitted only by the detailed serializer, and this list defaults to
          `format=standard`, so `include=attendance` on its own answers 200
          with no attendance data and nothing in the response to say why —
          note the example value below includes `attendance`, so following it
          literally is what surfaces this. Pass
          `format=detailed&include=attendance`. `GET /api/v1/shifts/{id}`
          needs no such pairing — its `format` already defaults to
          `detailed`. `teammates` and `notes` are unaffected: they render at
          every format.
        schema:
          type: string
          example: teammates,notes,attendance,user_scope,scope_users
      - name: format
        in: query
        description: Response detail level
        schema:
          type: string
          enum:
          - minimal
          - standard
          - detailed
          default: standard
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: List of shifts
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          managed_by:
                            type: string
                            nullable: true
                            example: UKG
                            description: 'The workforce management system of record
                              that owns this shift ("Managed in UKG"), or null. A
                              managed shift is read-only here: coverage requests and
                              edits answer 422 `externally_managed`.'
                          name:
                            type: string
                            example: Morning Shift
                          description:
                            type: string
                            nullable: true
                          start_time:
                            type: string
                            format: date-time
                            example: '2025-01-27T09:00:00Z'
                          end_time:
                            type: string
                            format: date-time
                            example: '2025-01-27T17:00:00Z'
                          formatted_date:
                            type: string
                            description: Human-readable date format
                            example: Monday, Jan 27
                          formatted_time:
                            type: string
                            description: Human-readable time range
                            example: 9:00 AM - 5:00 PM
                          status:
                            type: string
                            enum:
                            - scheduled
                            - completed
                            - cancelled
                            - open
                            example: scheduled
                          urgent:
                            type: boolean
                            description: Whether this shift is marked as urgent
                            example: false
                          needs_coverage:
                            type: boolean
                            description: Whether this shift needs coverage
                            example: false
                          can_list_for_coverage:
                            type: boolean
                            description: Whether the current user can list this shift
                              for coverage in the marketplace
                            example: true
                          is_already_listed:
                            type: boolean
                            description: Whether this shift is already listed in the
                              marketplace by the current user
                            example: false
                          required_users:
                            type: integer
                            description: Number of users required for this shift
                          location:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Downtown Store
                              address:
                                type: string
                                nullable: true
                              phone:
                                type: string
                                nullable: true
                          users:
                            type: array
                            items:
                              "$ref": "#/components/schemas/User"
                          available_actions:
                            type: array
                            description: Actions available to the current user for
                              this shift
                            items:
                              type: string
                              enum:
                              - view_details
                              - request_coverage
                              - report_lateness
                              - view_teammates
                              - claim
                            example:
                            - view_details
                            - request_coverage
                            - view_teammates
                          teammates:
                            type: array
                            description: Other users assigned to this shift (when
                              include=teammates)
                            nullable: true
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  description: User ID
                                name:
                                  type: string
                                  description: User full name
                                job_title:
                                  type: string
                                  nullable: true
                                  description: User's job title/role
                                profile_photo_url:
                                  type: string
                                  nullable: true
                                  description: Full-size profile photo URL (200x200)
                                    or avatar placeholder
                                profile_photo_thumbnail_url:
                                  type: string
                                  nullable: true
                                  description: Thumbnail profile photo URL (40x40)
                                    optimized for list views
                          listed_by:
                            type: object
                            nullable: true
                            description: User who listed this shift in the marketplace
                              (teammate format, present for marketplace shifts)
                            properties:
                              id:
                                type: integer
                                description: User ID
                              name:
                                type: string
                                description: User full name
                              job_title:
                                type: string
                                nullable: true
                                description: User's job title/role
                              profile_photo_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or avatar placeholder
                              profile_photo_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          picked_by:
                            type: object
                            nullable: true
                            description: User who claimed/picked up this shift from
                              the marketplace (teammate format, present when shift
                              was claimed)
                            properties:
                              id:
                                type: integer
                                description: User ID
                              name:
                                type: string
                                description: User full name
                              job_title:
                                type: string
                                nullable: true
                                description: User's job title/role
                              profile_photo_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or avatar placeholder
                              profile_photo_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          marketplace_info:
                            type: object
                            nullable: true
                            description: Marketplace listing information (present
                              when shift has marketplace listing)
                            properties:
                              has_listing:
                                type: boolean
                                description: Whether this shift has an active marketplace
                                  listing
                                example: true
                              listing_id:
                                type: integer
                                description: ID of the marketplace listing
                                example: 880
                              listing_status:
                                type: string
                                enum:
                                - open
                                - filled
                                - closed
                                - cancelled
                                description: Current status of the marketplace listing
                                example: open
                              lister_status:
                                type: string
                                enum:
                                - open
                                - filled
                                - traded
                                - closed
                                - cancelled
                                description: 'Present only when the listing is the
                                  caller''s own. The status as its lister reads it:
                                  listing_status, except a listing filled by an accepted
                                  trade is traded (filled = picked up). Switch on
                                  this key, never on lister_status_label.'
                                example: traded
                              lister_status_label:
                                type: string
                                description: Present only when the listing is the
                                  caller's own. The word the web and phone pages show
                                  the lister (Available, Covered, Traded, Closed,
                                  Cancelled), in the language the client asks for
                                  (the locale param, else Accept-Language, else the
                                  person's own language).
                                example: Traded
                              listing_type:
                                type: string
                                enum:
                                - pickup
                                - trade_only
                                - both
                                description: Type of marketplace listing - pickup
                                  (direct claim), trade_only (requires application/trade),
                                  or both (accepts either)
                                example: pickup
                              price:
                                type: number
                                format: float
                                description: Price offered for the shift
                                example: 0.01
                              currency:
                                type: string
                                description: Currency code (e.g., USD)
                                example: USD
                              listed_by:
                                type: integer
                                description: User ID of the person who listed the
                                  shift (legacy field, use listed_by object instead)
                                example: 1487
                              listed_at:
                                type: string
                                format: date-time
                                description: When the shift was listed in the marketplace
                                example: '2025-12-01T10:05:00Z'
                          notes:
                            type: string
                            nullable: true
                            description: Shift notes (when include=notes)
                          created_at:
                            type: string
                            format: date-time
                          updated_at:
                            type: string
                            format: date-time
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/CollectionPaginationMeta"
                      - type: object
                        properties:
                          current_time:
                            type: string
                            format: date-time
                            description: "`type=current` only: the server time the
                              window was measured from."
                          current_shift:
                            allOf:
                            - type: object
                              properties:
                                id:
                                  type: integer
                                  example: 123
                                managed_by:
                                  type: string
                                  nullable: true
                                  example: UKG
                                  description: 'The workforce management system of
                                    record that owns this shift ("Managed in UKG"),
                                    or null. A managed shift is read-only here: coverage
                                    requests and edits answer 422 `externally_managed`.'
                                name:
                                  type: string
                                  example: Morning Shift
                                description:
                                  type: string
                                  nullable: true
                                start_time:
                                  type: string
                                  format: date-time
                                  example: '2025-01-27T09:00:00Z'
                                end_time:
                                  type: string
                                  format: date-time
                                  example: '2025-01-27T17:00:00Z'
                                formatted_date:
                                  type: string
                                  description: Human-readable date format
                                  example: Monday, Jan 27
                                formatted_time:
                                  type: string
                                  description: Human-readable time range
                                  example: 9:00 AM - 5:00 PM
                                status:
                                  type: string
                                  enum:
                                  - scheduled
                                  - completed
                                  - cancelled
                                  - open
                                  example: scheduled
                                urgent:
                                  type: boolean
                                  description: Whether this shift is marked as urgent
                                  example: false
                                needs_coverage:
                                  type: boolean
                                  description: Whether this shift needs coverage
                                  example: false
                                can_list_for_coverage:
                                  type: boolean
                                  description: Whether the current user can list this
                                    shift for coverage in the marketplace
                                  example: true
                                is_already_listed:
                                  type: boolean
                                  description: Whether this shift is already listed
                                    in the marketplace by the current user
                                  example: false
                                required_users:
                                  type: integer
                                  description: Number of users required for this shift
                                location:
                                  type: object
                                  properties:
                                    id:
                                      type: integer
                                      example: 1
                                    name:
                                      type: string
                                      example: Downtown Store
                                    address:
                                      type: string
                                      nullable: true
                                    phone:
                                      type: string
                                      nullable: true
                                users:
                                  type: array
                                  items:
                                    "$ref": "#/components/schemas/User"
                                available_actions:
                                  type: array
                                  description: Actions available to the current user
                                    for this shift
                                  items:
                                    type: string
                                    enum:
                                    - view_details
                                    - request_coverage
                                    - report_lateness
                                    - view_teammates
                                    - claim
                                  example:
                                  - view_details
                                  - request_coverage
                                  - view_teammates
                                teammates:
                                  type: array
                                  description: Other users assigned to this shift
                                    (when include=teammates)
                                  nullable: true
                                  items:
                                    type: object
                                    properties:
                                      id:
                                        type: integer
                                        description: User ID
                                      name:
                                        type: string
                                        description: User full name
                                      job_title:
                                        type: string
                                        nullable: true
                                        description: User's job title/role
                                      profile_photo_url:
                                        type: string
                                        nullable: true
                                        description: Full-size profile photo URL (200x200)
                                          or avatar placeholder
                                      profile_photo_thumbnail_url:
                                        type: string
                                        nullable: true
                                        description: Thumbnail profile photo URL (40x40)
                                          optimized for list views
                                listed_by:
                                  type: object
                                  nullable: true
                                  description: User who listed this shift in the marketplace
                                    (teammate format, present for marketplace shifts)
                                  properties:
                                    id:
                                      type: integer
                                      description: User ID
                                    name:
                                      type: string
                                      description: User full name
                                    job_title:
                                      type: string
                                      nullable: true
                                      description: User's job title/role
                                    profile_photo_url:
                                      type: string
                                      nullable: true
                                      description: Full-size profile photo URL (200x200)
                                        or avatar placeholder
                                    profile_photo_thumbnail_url:
                                      type: string
                                      nullable: true
                                      description: Thumbnail profile photo URL (40x40)
                                        optimized for list views
                                picked_by:
                                  type: object
                                  nullable: true
                                  description: User who claimed/picked up this shift
                                    from the marketplace (teammate format, present
                                    when shift was claimed)
                                  properties:
                                    id:
                                      type: integer
                                      description: User ID
                                    name:
                                      type: string
                                      description: User full name
                                    job_title:
                                      type: string
                                      nullable: true
                                      description: User's job title/role
                                    profile_photo_url:
                                      type: string
                                      nullable: true
                                      description: Full-size profile photo URL (200x200)
                                        or avatar placeholder
                                    profile_photo_thumbnail_url:
                                      type: string
                                      nullable: true
                                      description: Thumbnail profile photo URL (40x40)
                                        optimized for list views
                                marketplace_info:
                                  type: object
                                  nullable: true
                                  description: Marketplace listing information (present
                                    when shift has marketplace listing)
                                  properties:
                                    has_listing:
                                      type: boolean
                                      description: Whether this shift has an active
                                        marketplace listing
                                      example: true
                                    listing_id:
                                      type: integer
                                      description: ID of the marketplace listing
                                      example: 880
                                    listing_status:
                                      type: string
                                      enum:
                                      - open
                                      - filled
                                      - closed
                                      - cancelled
                                      description: Current status of the marketplace
                                        listing
                                      example: open
                                    lister_status:
                                      type: string
                                      enum:
                                      - open
                                      - filled
                                      - traded
                                      - closed
                                      - cancelled
                                      description: 'Present only when the listing
                                        is the caller''s own. The status as its lister
                                        reads it: listing_status, except a listing
                                        filled by an accepted trade is traded (filled
                                        = picked up). Switch on this key, never on
                                        lister_status_label.'
                                      example: traded
                                    lister_status_label:
                                      type: string
                                      description: Present only when the listing is
                                        the caller's own. The word the web and phone
                                        pages show the lister (Available, Covered,
                                        Traded, Closed, Cancelled), in the language
                                        the client asks for (the locale param, else
                                        Accept-Language, else the person's own language).
                                      example: Traded
                                    listing_type:
                                      type: string
                                      enum:
                                      - pickup
                                      - trade_only
                                      - both
                                      description: Type of marketplace listing - pickup
                                        (direct claim), trade_only (requires application/trade),
                                        or both (accepts either)
                                      example: pickup
                                    price:
                                      type: number
                                      format: float
                                      description: Price offered for the shift
                                      example: 0.01
                                    currency:
                                      type: string
                                      description: Currency code (e.g., USD)
                                      example: USD
                                    listed_by:
                                      type: integer
                                      description: User ID of the person who listed
                                        the shift (legacy field, use listed_by object
                                        instead)
                                      example: 1487
                                    listed_at:
                                      type: string
                                      format: date-time
                                      description: When the shift was listed in the
                                        marketplace
                                      example: '2025-12-01T10:05:00Z'
                                notes:
                                  type: string
                                  nullable: true
                                  description: Shift notes (when include=notes)
                                created_at:
                                  type: string
                                  format: date-time
                                updated_at:
                                  type: string
                                  format: date-time
                            nullable: true
                          user_timezone:
                            type: string
                            example: America/New_York
                          type:
                            type: string
                            example: open_shifts
                          day_notes:
                            type: array
                            description: |
                              Your own shifts only (`type=assigned` or `user_id=current`, not
                              another person's): the schedule notes for the next 14 days of
                              published schedules at your locations or with your shifts, soonest
                              first — the same "Schedule notes" My Shifts shows on the web.
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                date:
                                  type: string
                                  format: date
                                  example: '2025-01-28'
                                body:
                                  type: string
                                  example: Quarterly department meeting at 2 PM
                                schedule_id:
                                  type: integer
                                schedule_name:
                                  type: string
                                  nullable: true
              examples:
                my_shifts:
                  summary: My assigned shifts
                  value:
                    items:
                    - id: 123
                      name: Morning Shift
                      date: Monday, Jan 27
                      start_time: '2025-01-27T09:00:00Z'
                      end_time: '2025-01-27T17:00:00Z'
                      location:
                        id: 1
                        name: Downtown Store
                      status: scheduled
                      urgent: false
                      needs_coverage: false
                      available_actions:
                      - view_details
                      - request_coverage
                      - view_teammates
                    meta:
                      total_count: 15
                      current_page: 1
                      total_pages: 2
                      per_page: 25
                      current_shift:
                      user_timezone: America/New_York
                open_shifts:
                  summary: Open shifts available for claiming
                  value:
                    items:
                    - id: 456
                      name: Evening Shift
                      date: Tuesday, Jan 28
                      start_time: '2025-01-28T14:00:00Z'
                      end_time: '2025-01-28T22:00:00Z'
                      location:
                        id: 2
                        name: Mall Location
                      status: open
                      urgent: true
                      needs_coverage: true
                      available_actions:
                      - claim
                      - view_details
                    meta:
                      total_count: 5
                      type: open_shifts
                marketplace_available:
                  summary: Marketplace - Available shifts to claim
                  value:
                    items:
                    - id: 789
                      name: Weekend Shift
                      date: Saturday, Feb 1
                      start_time: '2025-02-01T10:00:00Z'
                      end_time: '2025-02-01T18:00:00Z'
                      formatted_date: Saturday, Feb 01
                      formatted_time: 10:00 AM - 06:00 PM
                      location:
                        id: 3
                        name: North Branch
                      status: scheduled
                      urgent: false
                      needs_coverage: true
                      available_actions:
                      - claim
                      - view_details
                      marketplace_info:
                        has_listing: true
                        listing_id: 456
                        price: 50.0
                        currency: USD
                        listed_by: 123
                        listed_at: '2025-01-15T10:00:00Z'
                      listed_by:
                        id: 123
                        name: Grace Kulin
                        job_title: Product Manager
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/IMG_0130.jpg
                    meta:
                      total_count: 8
                      current_page: 1
                      total_pages: 1
                      per_page: 25
                      type: marketplace_available
                marketplace_claimed:
                  summary: Marketplace - Shifts I've claimed
                  value:
                    items:
                    - id: 890
                      name: Evening Shift
                      date: Thursday, Jan 30
                      start_time: '2025-01-30T15:00:00Z'
                      end_time: '2025-01-30T23:00:00Z'
                      formatted_date: Thursday, Jan 30
                      formatted_time: 03:00 PM - 11:00 PM
                      location:
                        id: 4
                        name: South Branch
                      status: scheduled
                      urgent: false
                      needs_coverage: false
                      available_actions:
                      - view_details
                      - request_coverage
                      marketplace_info:
                        has_listing: true
                        listing_id: 457
                        price: 45.0
                        currency: USD
                        listed_by: 456
                        listed_at: '2025-01-20T14:00:00Z'
                      listed_by:
                        id: 456
                        name: John Smith
                        job_title: Shift Supervisor
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/profile.jpg
                      picked_by:
                        id: 1256
                        name: Grace Kulin
                        job_title: Product Manager
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/IMG_0130.jpg
                    meta:
                      total_count: 3
                      current_page: 1
                      total_pages: 1
                      per_page: 25
                      type: marketplace_claimed
                marketplace_listed:
                  summary: Marketplace - Shifts I've listed
                  value:
                    items:
                    - id: 901
                      name: Late Night Shift
                      date: Friday, Feb 7
                      start_time: '2025-02-07T22:00:00Z'
                      end_time: '2025-02-08T06:00:00Z'
                      formatted_date: Friday, Feb 07
                      formatted_time: 10:00 PM - 06:00 AM
                      location:
                        id: 1
                        name: Downtown Store
                      status: scheduled
                      urgent: false
                      needs_coverage: true
                      available_actions:
                      - view_details
                      marketplace_info:
                        has_listing: true
                        listing_id: 458
                        price: 75.0
                        currency: USD
                        listed_by: 789
                        listed_at: '2025-01-25T09:00:00Z'
                      listed_by:
                        id: 789
                        name: Sarah Johnson
                        job_title: Operations Manager
                        profile_photo_url: https://hub.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NTk1LCJwdXIiOiJibG9iX2lkIn19--3c6dbf568dc4bf5029665ab667828e9ef1ddb58e/sarah.jpg
                    meta:
                      total_count: 2
                      current_page: 1
                      total_pages: 1
                      per_page: 25
                      type: marketplace_listed
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shifts
      summary: Create a shift
      description: |
        Creates one shift at the organization's default location, and optionally assigns people to it.
        Requires the `write:shifts` scope, a manager-or-above role and the Shifts **Create** permission
        (a manager limited to some sites may create only where they manage).

        - The shift fields are wrapped in `shift`. `location_id` is NOT accepted here: sending it answers
          400 `location_id_not_settable`. To create a shift at a specific location use
          `POST /shifts/bulk_create` (one row is fine).
        - `user_ids` (top level, beside `shift`) assigns people; each must belong to this organization.
          Assignees are notified after the shift is saved.
        - Coverage (`needs_coverage`) is computed from the roster after the save; it cannot be sent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shift
              properties:
                shift:
                  type: object
                  description: |
                    The shift's fields. On create, `name`, `start_time` and `end_time` are required. `location_id`
                    is refused on create (400) and ignored on update; `needs_coverage` is computed, never sent.
                    An explicit `"urgent": null` is ignored (the flag is left as it was).
                  properties:
                    name:
                      type: string
                    description:
                      type: string
                      nullable: true
                    start_time:
                      type: string
                      format: date-time
                    end_time:
                      type: string
                      format: date-time
                    status:
                      type: string
                      description: Defaults to the shift's initial status when omitted
                    location_department_id:
                      type: integer
                      nullable: true
                      description: A department of the shift's location
                    required_staff_count:
                      type: integer
                    urgent:
                      type: boolean
                    shift_label_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    planned_breaks_attributes:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            description: An existing planned break to change (omit
                              to add one)
                          break_type_id:
                            type: integer
                          timing_type:
                            type: string
                          scheduled_time:
                            type: string
                          window_start:
                            type: string
                          window_end:
                            type: string
                          timing_preferences:
                            type: object
                          is_required:
                            type: boolean
                          _destroy:
                            type: boolean
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: |
                    People to assign (members of this organization). Omit to leave the roster alone; on update,
                    an empty array clears it.
            example:
              shift:
                name: Morning Shift
                start_time: '2026-10-05T08:00:00Z'
                end_time: '2026-10-05T16:00:00Z'
                required_staff_count: 3
                notes: Truck delivery at 9
              user_ids:
              - 101
              - 102
      responses:
        '201':
          description: Shift created
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - name
                - start_time
                - end_time
                - status
                - created_at
                - updated_at
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  description:
                    type: string
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  status:
                    type: string
                  location_id:
                    type: integer
                    nullable: true
                  location_department_id:
                    type: integer
                    nullable: true
                  schedule_id:
                    type: integer
                    nullable: true
                  scheduling_group_id:
                    type: integer
                    nullable: true
                  scheduling_sub_group_id:
                    type: integer
                    nullable: true
                  shift_type:
                    type: string
                    nullable: true
                  required_staff_count:
                    type: integer
                    nullable: true
                  needs_coverage:
                    type: boolean
                  urgent:
                    type: boolean
                  ends_next_day:
                    type: boolean
                    nullable: true
                  is_open_shift:
                    type: boolean
                    nullable: true
                  is_ad_hoc:
                    type: boolean
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  location:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        first_name:
                          type: string
                        last_name:
                          type: string
                        name:
                          type: string
                        job_title:
                          type: string
                          nullable: true
                  planned_breaks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        break_type_id:
                          type: integer
                        timing_type:
                          type: string
                        scheduled_time:
                          type: string
                          nullable: true
                        window_start:
                          type: string
                          nullable: true
                        window_end:
                          type: string
                          nullable: true
                        duration_minutes:
                          type: integer
                          nullable: true
                        is_required:
                          type: boolean
        '400':
          description: |
            `location_id_not_settable` (location_id was sent — use `POST /shifts/bulk_create`),
            or the `shift` object is missing.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: location_id_not_settable
                  message: location_id cannot be set on this endpoint. Use POST /api/v1/shifts/bulk_create
                    to create a shift at a specific location.
                  details:
                    param: shift[location_id]
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `error.code` says which gate refused it, and `error.message` why:
            * `insufficient_permissions` — the token lacks `write:shifts`
              (`{"error": {"code": "insufficient_permissions", "message": "Required scope: write:shifts"}}`);
            * `forbidden` — the person's role, site or Shifts permission refused it:
              `{"error": {"code": "forbidden", "message": "You don't have permission to manage shifts"}}` (not a manager),
              `{"error": {"code": "forbidden", "message": "You do not have permission to manage shifts at this location"}}` (another site),
              `{"error": {"code": "forbidden", "message": "Not authorized to access this shift"}}` (a shift they may not see; update and delete only), or
              `{"error": {"code": "forbidden", "message": "You don't have permission to <create|edit|delete> shifts."}, "status": "error", "success": false, "message": "…"}`
              (the Shifts Create / Edit / Delete permission).
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    required:
                    - code
                    - message
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  message:
                    type: string
                  status:
                    type: string
                  success:
                    type: boolean
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shifts/bulk_create":
    post:
      tags:
      - Shifts
      summary: Create several shifts
      description: |
        Creates each row as its own shift; a failed row does not stop the others. Unlike `POST /shifts`,
        a row may name its `location_id`. Rows carry no roster: every shift is created unstaffed, so no one
        is notified. A row with a `schedule_id` in a published schedule is published at once.
        Requires `write:shifts`, a manager-or-above role and the Shifts **Create** permission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shifts
              properties:
                shifts:
                  type: array
                  items:
                    type: object
                    description: One row of POST /shifts/bulk_create or PUT /shifts/bulk_update
                      (unwrapped — no `shift` key).
                    properties:
                      name:
                        type: string
                      description:
                        type: string
                        nullable: true
                      start_time:
                        type: string
                        format: date-time
                      end_time:
                        type: string
                        format: date-time
                      status:
                        type: string
                      location_id:
                        type: integer
                      location_department_id:
                        type: integer
                        nullable: true
                      schedule_id:
                        type: integer
                        nullable: true
                      required_staff_count:
                        type: integer
                      urgent:
                        type: boolean
                      ends_next_day:
                        type: boolean
                      is_open_shift:
                        type: boolean
                      notes:
                        type: string
                        nullable: true
            example:
              shifts:
              - name: Morning Shift
                location_id: 12
                start_time: '2026-10-05T08:00:00Z'
                end_time: '2026-10-05T16:00:00Z'
                required_staff_count: 3
      responses:
        '200':
          description: Every row was created
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: All shifts created successfully
        '400':
          description: "`invalid_shifts_payload` — `shifts` is not an array of shift
            objects"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Some rows failed. The rows in `results.success` WERE created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Some shifts failed to create
                  results:
                    type: object
                    properties:
                      success:
                        type: array
                        description: IDs of the shifts that were written
                        items:
                          type: integer
                      failed:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              description: bulk_update only
                            params:
                              type: object
                              description: bulk_create only — the row as received
                            errors:
                              type: array
                              items:
                                type: string
                            code:
                              type: string
                              description: "`externally_managed` when the shift is
                                managed in the workforce management system"
  "/shifts/bulk_update":
    put:
      tags:
      - Shifts
      summary: Update several shifts
      description: |
        Updates each row (matched by `id` in this organization); a failed row does not stop the others.
        A row that moves a shift to another `schedule_id` takes that schedule's published state.
        A shift managed in the workforce management system (e.g. UKG) is refused per row with
        `code: externally_managed`. With the organization's "edit past shifts" setting off, a request that
        touches a shift that has already ended in a published schedule is refused whole
        (422 `past_shift_locked`). People assigned to a materially changed shift are notified once each.
        Requires `write:shifts`, a manager-or-above role and the Shifts **Edit** permission.

        A row that cancels a shift (`status` set to `cancelled`) makes the call
        delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:scheduling` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shifts
              properties:
                shifts:
                  type: array
                  items:
                    allOf:
                    - type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                    - type: object
                      description: One row of POST /shifts/bulk_create or PUT /shifts/bulk_update
                        (unwrapped — no `shift` key).
                      properties:
                        name:
                          type: string
                        description:
                          type: string
                          nullable: true
                        start_time:
                          type: string
                          format: date-time
                        end_time:
                          type: string
                          format: date-time
                        status:
                          type: string
                        location_id:
                          type: integer
                        location_department_id:
                          type: integer
                          nullable: true
                        schedule_id:
                          type: integer
                          nullable: true
                        required_staff_count:
                          type: integer
                        urgent:
                          type: boolean
                        ends_next_day:
                          type: boolean
                        is_open_shift:
                          type: boolean
                        notes:
                          type: string
                          nullable: true
            example:
              shifts:
              - id: 3901
                start_time: '2026-10-05T09:00:00Z'
                end_time: '2026-10-05T17:00:00Z'
      responses:
        '200':
          description: Every row was updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: All shifts updated successfully
        '400':
          description: "`invalid_shifts_payload` — `shifts` is not an array of shift
            objects"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Some rows failed (the rows in `results.success` WERE updated), or `past_shift_locked`
            (nothing was changed; `details.shift_ids` names the locked shifts).
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    message:
                      type: string
                      example: Some shifts failed to create
                    results:
                      type: object
                      properties:
                        success:
                          type: array
                          description: IDs of the shifts that were written
                          items:
                            type: integer
                        failed:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                description: bulk_update only
                              params:
                                type: object
                                description: bulk_create only — the row as received
                              errors:
                                type: array
                                items:
                                  type: string
                              code:
                                type: string
                                description: "`externally_managed` when the shift
                                  is managed in the workforce management system"
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
  "/shifts/{id}":
    get:
      tags:
      - Shifts
      summary: Get shift details
      description: |
        Retrieves detailed information about a specific shift, including:
        - Basic shift information (name, times, location, status)
        - Detailed location information
        - Shift details (description, notes, staffing counts)
        - **Marketplace listing information** (when available)
          - `listing_type`: Type of listing (pickup, trade_only, both)
          - `listing_status`: Current status (open, filled, closed, cancelled)
          - Price, currency, and listing metadata
        - Available actions for the current user
        - Assignment capabilities
      parameters:
      - name: id
        in: path
        required: true
        description: Shift ID
        schema:
          type: integer
          example: 3901
      - name: format
        in: query
        required: false
        description: Response format type
        schema:
          type: string
          enum:
          - minimal
          - standard
          - detailed
          default: detailed
      - name: include
        in: query
        required: false
        description: Additional fields to include (comma-separated)
        schema:
          type: string
          example: teammates,notes,attendance
      responses:
        '200':
          description: Shift details retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    id:
                      type: integer
                      example: 123
                    managed_by:
                      type: string
                      nullable: true
                      example: UKG
                      description: 'The workforce management system of record that
                        owns this shift ("Managed in UKG"), or null. A managed shift
                        is read-only here: coverage requests and edits answer 422
                        `externally_managed`.'
                    name:
                      type: string
                      example: Morning Shift
                    description:
                      type: string
                      nullable: true
                    start_time:
                      type: string
                      format: date-time
                      example: '2025-01-27T09:00:00Z'
                    end_time:
                      type: string
                      format: date-time
                      example: '2025-01-27T17:00:00Z'
                    formatted_date:
                      type: string
                      description: Human-readable date format
                      example: Monday, Jan 27
                    formatted_time:
                      type: string
                      description: Human-readable time range
                      example: 9:00 AM - 5:00 PM
                    status:
                      type: string
                      enum:
                      - scheduled
                      - completed
                      - cancelled
                      - open
                      example: scheduled
                    urgent:
                      type: boolean
                      description: Whether this shift is marked as urgent
                      example: false
                    needs_coverage:
                      type: boolean
                      description: Whether this shift needs coverage
                      example: false
                    can_list_for_coverage:
                      type: boolean
                      description: Whether the current user can list this shift for
                        coverage in the marketplace
                      example: true
                    is_already_listed:
                      type: boolean
                      description: Whether this shift is already listed in the marketplace
                        by the current user
                      example: false
                    required_users:
                      type: integer
                      description: Number of users required for this shift
                    location:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 1
                        name:
                          type: string
                          example: Downtown Store
                        address:
                          type: string
                          nullable: true
                        phone:
                          type: string
                          nullable: true
                    users:
                      type: array
                      items:
                        "$ref": "#/components/schemas/User"
                    available_actions:
                      type: array
                      description: Actions available to the current user for this
                        shift
                      items:
                        type: string
                        enum:
                        - view_details
                        - request_coverage
                        - report_lateness
                        - view_teammates
                        - claim
                      example:
                      - view_details
                      - request_coverage
                      - view_teammates
                    teammates:
                      type: array
                      description: Other users assigned to this shift (when include=teammates)
                      nullable: true
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            description: User ID
                          name:
                            type: string
                            description: User full name
                          job_title:
                            type: string
                            nullable: true
                            description: User's job title/role
                          profile_photo_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              avatar placeholder
                          profile_photo_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                    listed_by:
                      type: object
                      nullable: true
                      description: User who listed this shift in the marketplace (teammate
                        format, present for marketplace shifts)
                      properties:
                        id:
                          type: integer
                          description: User ID
                        name:
                          type: string
                          description: User full name
                        job_title:
                          type: string
                          nullable: true
                          description: User's job title/role
                        profile_photo_url:
                          type: string
                          nullable: true
                          description: Full-size profile photo URL (200x200) or avatar
                            placeholder
                        profile_photo_thumbnail_url:
                          type: string
                          nullable: true
                          description: Thumbnail profile photo URL (40x40) optimized
                            for list views
                    picked_by:
                      type: object
                      nullable: true
                      description: User who claimed/picked up this shift from the
                        marketplace (teammate format, present when shift was claimed)
                      properties:
                        id:
                          type: integer
                          description: User ID
                        name:
                          type: string
                          description: User full name
                        job_title:
                          type: string
                          nullable: true
                          description: User's job title/role
                        profile_photo_url:
                          type: string
                          nullable: true
                          description: Full-size profile photo URL (200x200) or avatar
                            placeholder
                        profile_photo_thumbnail_url:
                          type: string
                          nullable: true
                          description: Thumbnail profile photo URL (40x40) optimized
                            for list views
                    marketplace_info:
                      type: object
                      nullable: true
                      description: Marketplace listing information (present when shift
                        has marketplace listing)
                      properties:
                        has_listing:
                          type: boolean
                          description: Whether this shift has an active marketplace
                            listing
                          example: true
                        listing_id:
                          type: integer
                          description: ID of the marketplace listing
                          example: 880
                        listing_status:
                          type: string
                          enum:
                          - open
                          - filled
                          - closed
                          - cancelled
                          description: Current status of the marketplace listing
                          example: open
                        lister_status:
                          type: string
                          enum:
                          - open
                          - filled
                          - traded
                          - closed
                          - cancelled
                          description: 'Present only when the listing is the caller''s
                            own. The status as its lister reads it: listing_status,
                            except a listing filled by an accepted trade is traded
                            (filled = picked up). Switch on this key, never on lister_status_label.'
                          example: traded
                        lister_status_label:
                          type: string
                          description: Present only when the listing is the caller's
                            own. The word the web and phone pages show the lister
                            (Available, Covered, Traded, Closed, Cancelled), in the
                            language the client asks for (the locale param, else Accept-Language,
                            else the person's own language).
                          example: Traded
                        listing_type:
                          type: string
                          enum:
                          - pickup
                          - trade_only
                          - both
                          description: Type of marketplace listing - pickup (direct
                            claim), trade_only (requires application/trade), or both
                            (accepts either)
                          example: pickup
                        price:
                          type: number
                          format: float
                          description: Price offered for the shift
                          example: 0.01
                        currency:
                          type: string
                          description: Currency code (e.g., USD)
                          example: USD
                        listed_by:
                          type: integer
                          description: User ID of the person who listed the shift
                            (legacy field, use listed_by object instead)
                          example: 1487
                        listed_at:
                          type: string
                          format: date-time
                          description: When the shift was listed in the marketplace
                          example: '2025-12-01T10:05:00Z'
                    notes:
                      type: string
                      nullable: true
                      description: Shift notes (when include=notes)
                    created_at:
                      type: string
                      format: date-time
                    updated_at:
                      type: string
                      format: date-time
                - type: object
                  properties:
                    can_list_for_coverage:
                      type: boolean
                      description: Whether the current user can list this shift for
                        coverage
                      example: true
                    is_already_listed:
                      type: boolean
                      description: Whether this shift is already listed by the current
                        user
                      example: false
                    placements:
                      type: array
                      maxItems: 1
                      description: |
                        Communications messages booked into THIS shift for the
                        caller (audience, dismissal and acknowledgement already
                        applied) — at most one today; an array so the shape can
                        grow. Rendering it counts as "shown", as on the web.
                      items:
                        "$ref": "#/components/schemas/CommsPlacement"
              examples:
                shift_with_marketplace_listing:
                  summary: Shift with marketplace listing
                  value:
                    id: 3901
                    name: Full Day Coverage - Pune Office
                    start_time: '2025-12-06T04:30:00Z'
                    end_time: '2025-12-06T13:00:00Z'
                    formatted_date: Friday, Dec 06
                    formatted_time: 04:30 AM - 01:00 PM
                    location:
                      id: 42
                      name: Pune Office
                    status: scheduled
                    urgent: false
                    needs_coverage: false
                    available_actions:
                    - view_details
                    - request_coverage
                    marketplace_info:
                      has_listing: true
                      listing_id: 880
                      listing_status: open
                      listing_type: pickup
                      price: '0.01'
                      currency: USD
                      listed_by: 1487
                      listed_at: '2025-12-01T10:05:00Z'
                    listed_by:
                      id: 1487
                      name: John Doe
                      job_title: Operations Manager
                      profile_photo_url: https://hub.workforce.mangoapps.com/avatar/1487
                    location_details:
                      id: 42
                      name: Pune Office
                      address: 123 Tech Park, Pune
                      phone: "+91-20-12345678"
                    shift_details:
                      description: Regular operations shift
                      notes: Remember to bring your access card
                      required_staff_count: 5
                      current_staff_count: 4
                    can_list_for_coverage: false
                    is_already_listed: true
                shift_without_marketplace:
                  summary: Shift without marketplace listing
                  value:
                    id: 767
                    name: Operations - Early Shift
                    start_time: '2025-09-23T14:00:00Z'
                    end_time: '2025-09-23T22:30:00Z'
                    formatted_date: Tuesday, Sep 23
                    formatted_time: 02:00 PM - 10:30 PM
                    location:
                      id: 265
                      name: Issaquah Office
                    status: scheduled
                    urgent: false
                    needs_coverage: false
                    available_actions:
                    - view_details
                    location_details:
                      id: 265
                      name: Issaquah Office
                      address: 1495 11th Avenue Northwest
                      phone: ''
                    shift_details:
                      description: ''
                      notes:
                      required_staff_count: 4
                      current_staff_count: 4
                    can_list_for_coverage: false
                    is_already_listed: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Not authorized to access this shift (`error.code` is `forbidden`).
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    required:
                    - code
                    - message
                    properties:
                      code:
                        type: string
                      message:
                        type: string
              example:
                error:
                  code: forbidden
                  message: Not authorized to access this shift
        '404':
          description: Shift not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: shift_not_found
                  message: Shift not found
    patch:
      tags:
      - Shifts
      summary: Update a shift
      description: |
        Updates the shift's own fields (wrapped in `shift`) and, when `user_ids` is sent, replaces its roster
        (`user_ids: []` clears it). `location_id` is not permitted here and is ignored. People assigned to
        the shift are told about a material change (time, place, cancellation) after it is saved.
        Requires `write:shifts`, a manager-or-above role, the Shifts **Edit** permission, and access to this
        shift's site. Refused with 422 when the shift is managed in the workforce management system
        (`externally_managed`), or has already ended in a published schedule while the organization's
        "edit past shifts" setting is off (`past_shift_locked`).

        A roster that takes off someone working the shift is delete-level (owner decision
        2026-10-04): that request also requires `destructive:scheduling`. Adding people, or
        dropping someone who declined, needs only `write:shifts`.

        Cancelling a shift (`shift.status` set to `cancelled`) is delete-level (owner
        decision 2026-10-04, #6721): requires the `destructive:scheduling` scope.
      parameters:
      - name: id
        in: path
        required: true
        description: Shift ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shift
              properties:
                shift:
                  type: object
                  description: |
                    The shift's fields. On create, `name`, `start_time` and `end_time` are required. `location_id`
                    is refused on create (400) and ignored on update; `needs_coverage` is computed, never sent.
                    An explicit `"urgent": null` is ignored (the flag is left as it was).
                  properties:
                    name:
                      type: string
                    description:
                      type: string
                      nullable: true
                    start_time:
                      type: string
                      format: date-time
                    end_time:
                      type: string
                      format: date-time
                    status:
                      type: string
                      description: Defaults to the shift's initial status when omitted
                    location_department_id:
                      type: integer
                      nullable: true
                      description: A department of the shift's location
                    required_staff_count:
                      type: integer
                    urgent:
                      type: boolean
                    shift_label_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    planned_breaks_attributes:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            description: An existing planned break to change (omit
                              to add one)
                          break_type_id:
                            type: integer
                          timing_type:
                            type: string
                          scheduled_time:
                            type: string
                          window_start:
                            type: string
                          window_end:
                            type: string
                          timing_preferences:
                            type: object
                          is_required:
                            type: boolean
                          _destroy:
                            type: boolean
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: |
                    People to assign (members of this organization). Omit to leave the roster alone; on update,
                    an empty array clears it.
            example:
              shift:
                end_time: '2026-10-05T17:00:00Z'
                required_staff_count: 4
      responses:
        '200':
          description: Shift updated
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - name
                - start_time
                - end_time
                - status
                - created_at
                - updated_at
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  description:
                    type: string
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  status:
                    type: string
                  location_id:
                    type: integer
                    nullable: true
                  location_department_id:
                    type: integer
                    nullable: true
                  schedule_id:
                    type: integer
                    nullable: true
                  scheduling_group_id:
                    type: integer
                    nullable: true
                  scheduling_sub_group_id:
                    type: integer
                    nullable: true
                  shift_type:
                    type: string
                    nullable: true
                  required_staff_count:
                    type: integer
                    nullable: true
                  needs_coverage:
                    type: boolean
                  urgent:
                    type: boolean
                  ends_next_day:
                    type: boolean
                    nullable: true
                  is_open_shift:
                    type: boolean
                    nullable: true
                  is_ad_hoc:
                    type: boolean
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  location:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        first_name:
                          type: string
                        last_name:
                          type: string
                        name:
                          type: string
                        job_title:
                          type: string
                          nullable: true
                  planned_breaks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        break_type_id:
                          type: integer
                        timing_type:
                          type: string
                        scheduled_time:
                          type: string
                          nullable: true
                        window_start:
                          type: string
                          nullable: true
                        window_end:
                          type: string
                          nullable: true
                        duration_minutes:
                          type: integer
                          nullable: true
                        is_required:
                          type: boolean
        '400':
          description: The `shift` object is missing
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `error.code` says which gate refused it, and `error.message` why:
            * `insufficient_permissions` — the token lacks `write:shifts`
              (`{"error": {"code": "insufficient_permissions", "message": "Required scope: write:shifts"}}`);
            * `forbidden` — the person's role, site or Shifts permission refused it:
              `{"error": {"code": "forbidden", "message": "You don't have permission to manage shifts"}}` (not a manager),
              `{"error": {"code": "forbidden", "message": "You do not have permission to manage shifts at this location"}}` (another site),
              `{"error": {"code": "forbidden", "message": "Not authorized to access this shift"}}` (a shift they may not see; update and delete only), or
              `{"error": {"code": "forbidden", "message": "You don't have permission to <create|edit|delete> shifts."}, "status": "error", "success": false, "message": "…"}`
              (the Shifts Create / Edit / Delete permission).
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    required:
                    - code
                    - message
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  message:
                    type: string
                  status:
                    type: string
                  success:
                    type: boolean
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Validation failed (`errors[]`, one entry per attribute), or `externally_managed` /
            `past_shift_locked` (an `error` object).
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            description: Field name with validation error
                          message:
                            type: string
                            description: Validation error message
                          code:
                            type: string
                            description: Always `validation_error`
                          field_name:
                            type: string
                            description: |
                              Present only when an error on `base` is about one named field — a
                              survey or form question left blank (`"<label> is required"`). The
                              question's key, as sent in `submission_data`.
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
    put:
      tags:
      - Shifts
      summary: Update a shift (PUT)
      description: |
        Updates the shift's own fields (wrapped in `shift`) and, when `user_ids` is sent, replaces its roster
        (`user_ids: []` clears it). `location_id` is not permitted here and is ignored. People assigned to
        the shift are told about a material change (time, place, cancellation) after it is saved.
        Requires `write:shifts`, a manager-or-above role, the Shifts **Edit** permission, and access to this
        shift's site. Refused with 422 when the shift is managed in the workforce management system
        (`externally_managed`), or has already ended in a published schedule while the organization's
        "edit past shifts" setting is off (`past_shift_locked`).

        A roster that takes off someone working the shift is delete-level (owner decision
        2026-10-04): that request also requires `destructive:scheduling`. Adding people, or
        dropping someone who declined, needs only `write:shifts`.

        Cancelling a shift (`shift.status` set to `cancelled`) is delete-level (owner
        decision 2026-10-04, #6721): requires the `destructive:scheduling` scope.
      parameters:
      - name: id
        in: path
        required: true
        description: Shift ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shift
              properties:
                shift:
                  type: object
                  description: |
                    The shift's fields. On create, `name`, `start_time` and `end_time` are required. `location_id`
                    is refused on create (400) and ignored on update; `needs_coverage` is computed, never sent.
                    An explicit `"urgent": null` is ignored (the flag is left as it was).
                  properties:
                    name:
                      type: string
                    description:
                      type: string
                      nullable: true
                    start_time:
                      type: string
                      format: date-time
                    end_time:
                      type: string
                      format: date-time
                    status:
                      type: string
                      description: Defaults to the shift's initial status when omitted
                    location_department_id:
                      type: integer
                      nullable: true
                      description: A department of the shift's location
                    required_staff_count:
                      type: integer
                    urgent:
                      type: boolean
                    shift_label_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    planned_breaks_attributes:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            description: An existing planned break to change (omit
                              to add one)
                          break_type_id:
                            type: integer
                          timing_type:
                            type: string
                          scheduled_time:
                            type: string
                          window_start:
                            type: string
                          window_end:
                            type: string
                          timing_preferences:
                            type: object
                          is_required:
                            type: boolean
                          _destroy:
                            type: boolean
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: |
                    People to assign (members of this organization). Omit to leave the roster alone; on update,
                    an empty array clears it.
            example:
              shift:
                end_time: '2026-10-05T17:00:00Z'
                required_staff_count: 4
      responses:
        '200':
          description: Shift updated
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - name
                - start_time
                - end_time
                - status
                - created_at
                - updated_at
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  description:
                    type: string
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  status:
                    type: string
                  location_id:
                    type: integer
                    nullable: true
                  location_department_id:
                    type: integer
                    nullable: true
                  schedule_id:
                    type: integer
                    nullable: true
                  scheduling_group_id:
                    type: integer
                    nullable: true
                  scheduling_sub_group_id:
                    type: integer
                    nullable: true
                  shift_type:
                    type: string
                    nullable: true
                  required_staff_count:
                    type: integer
                    nullable: true
                  needs_coverage:
                    type: boolean
                  urgent:
                    type: boolean
                  ends_next_day:
                    type: boolean
                    nullable: true
                  is_open_shift:
                    type: boolean
                    nullable: true
                  is_ad_hoc:
                    type: boolean
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  location:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        first_name:
                          type: string
                        last_name:
                          type: string
                        name:
                          type: string
                        job_title:
                          type: string
                          nullable: true
                  planned_breaks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        break_type_id:
                          type: integer
                        timing_type:
                          type: string
                        scheduled_time:
                          type: string
                          nullable: true
                        window_start:
                          type: string
                          nullable: true
                        window_end:
                          type: string
                          nullable: true
                        duration_minutes:
                          type: integer
                          nullable: true
                        is_required:
                          type: boolean
        '400':
          description: The `shift` object is missing
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `error.code` says which gate refused it, and `error.message` why:
            * `insufficient_permissions` — the token lacks `write:shifts`
              (`{"error": {"code": "insufficient_permissions", "message": "Required scope: write:shifts"}}`);
            * `forbidden` — the person's role, site or Shifts permission refused it:
              `{"error": {"code": "forbidden", "message": "You don't have permission to manage shifts"}}` (not a manager),
              `{"error": {"code": "forbidden", "message": "You do not have permission to manage shifts at this location"}}` (another site),
              `{"error": {"code": "forbidden", "message": "Not authorized to access this shift"}}` (a shift they may not see; update and delete only), or
              `{"error": {"code": "forbidden", "message": "You don't have permission to <create|edit|delete> shifts."}, "status": "error", "success": false, "message": "…"}`
              (the Shifts Create / Edit / Delete permission).
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    required:
                    - code
                    - message
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  message:
                    type: string
                  status:
                    type: string
                  success:
                    type: boolean
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Validation failed (`errors[]`, one entry per attribute), or `externally_managed` /
            `past_shift_locked` (an `error` object).
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            description: Field name with validation error
                          message:
                            type: string
                            description: Validation error message
                          code:
                            type: string
                            description: Always `validation_error`
                          field_name:
                            type: string
                            description: |
                              Present only when an error on `base` is about one named field — a
                              survey or form question left blank (`"<label> is required"`). The
                              question's key, as sent in `submission_data`.
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
    delete:
      tags:
      - Shifts
      summary: Delete a shift
      description: |
        Deletes the shift; people actively assigned to it are told. Refused while the shift has attendance
        records. Requires `write:shifts`, a manager-or-above role, the Shifts **Delete** permission and access
        to this shift's site. Refused with 422 when the shift is managed in the workforce management system
        (`externally_managed`) or has already ended in a published schedule while "edit past shifts" is off
        (`past_shift_locked`).

        Delete-level (owner decision 2026-10-04): requires the `destructive:scheduling`
        scope.
      parameters:
      - name: id
        in: path
        required: true
        description: Shift ID
        schema:
          type: integer
      responses:
        '204':
          description: Shift deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `error.code` says which gate refused it, and `error.message` why:
            * `insufficient_permissions` — the token lacks `write:shifts`
              (`{"error": {"code": "insufficient_permissions", "message": "Required scope: write:shifts"}}`);
            * `forbidden` — the person's role, site or Shifts permission refused it:
              `{"error": {"code": "forbidden", "message": "You don't have permission to manage shifts"}}` (not a manager),
              `{"error": {"code": "forbidden", "message": "You do not have permission to manage shifts at this location"}}` (another site),
              `{"error": {"code": "forbidden", "message": "Not authorized to access this shift"}}` (a shift they may not see; update and delete only), or
              `{"error": {"code": "forbidden", "message": "You don't have permission to <create|edit|delete> shifts."}, "status": "error", "success": false, "message": "…"}`
              (the Shifts Create / Edit / Delete permission).
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    required:
                    - code
                    - message
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  message:
                    type: string
                  status:
                    type: string
                  success:
                    type: boolean
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            The shift has attendance records
            (`{"error": {"code": "validation_failed", "message": "Cannot delete shift with associated attendance records"}}`),
            or `error.code` `externally_managed` / `past_shift_locked`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    required:
                    - code
                    - message
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/shifts/{id}/claim":
    post:
      tags:
      - Shifts
      summary: Claim an open shift
      description: |
        Employee claims an available open shift. Where the company's claim
        approval setting approves the claim at once, the shift is assigned and
        the response carries `assignment`. Where the claim needs approval
        (as Smart Approval decides claim by claim, or always under "Require
        Manager Approval for All Claims"), a pending claim request is filed
        instead and the response
        carries `claim_request`; the shift is the claimant's only once an
        approver approves it. Both answer 200.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: |
            The claim was taken. `assignment` when the shift was assigned at
            once; `claim_request` (status `pending`) when it waits for approval.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Shift claimed successfully
                  shift:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 123
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: 'The workforce management system of record that
                          owns this shift ("Managed in UKG"), or null. A managed shift
                          is read-only here: coverage requests and edits answer 422
                          `externally_managed`.'
                      name:
                        type: string
                        example: Morning Shift
                      description:
                        type: string
                        nullable: true
                      start_time:
                        type: string
                        format: date-time
                        example: '2025-01-27T09:00:00Z'
                      end_time:
                        type: string
                        format: date-time
                        example: '2025-01-27T17:00:00Z'
                      formatted_date:
                        type: string
                        description: Human-readable date format
                        example: Monday, Jan 27
                      formatted_time:
                        type: string
                        description: Human-readable time range
                        example: 9:00 AM - 5:00 PM
                      status:
                        type: string
                        enum:
                        - scheduled
                        - completed
                        - cancelled
                        - open
                        example: scheduled
                      urgent:
                        type: boolean
                        description: Whether this shift is marked as urgent
                        example: false
                      needs_coverage:
                        type: boolean
                        description: Whether this shift needs coverage
                        example: false
                      can_list_for_coverage:
                        type: boolean
                        description: Whether the current user can list this shift
                          for coverage in the marketplace
                        example: true
                      is_already_listed:
                        type: boolean
                        description: Whether this shift is already listed in the marketplace
                          by the current user
                        example: false
                      required_users:
                        type: integer
                        description: Number of users required for this shift
                      location:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 1
                          name:
                            type: string
                            example: Downtown Store
                          address:
                            type: string
                            nullable: true
                          phone:
                            type: string
                            nullable: true
                      users:
                        type: array
                        items:
                          "$ref": "#/components/schemas/User"
                      available_actions:
                        type: array
                        description: Actions available to the current user for this
                          shift
                        items:
                          type: string
                          enum:
                          - view_details
                          - request_coverage
                          - report_lateness
                          - view_teammates
                          - claim
                        example:
                        - view_details
                        - request_coverage
                        - view_teammates
                      teammates:
                        type: array
                        description: Other users assigned to this shift (when include=teammates)
                        nullable: true
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              description: User ID
                            name:
                              type: string
                              description: User full name
                            job_title:
                              type: string
                              nullable: true
                              description: User's job title/role
                            profile_photo_url:
                              type: string
                              nullable: true
                              description: Full-size profile photo URL (200x200) or
                                avatar placeholder
                            profile_photo_thumbnail_url:
                              type: string
                              nullable: true
                              description: Thumbnail profile photo URL (40x40) optimized
                                for list views
                      listed_by:
                        type: object
                        nullable: true
                        description: User who listed this shift in the marketplace
                          (teammate format, present for marketplace shifts)
                        properties:
                          id:
                            type: integer
                            description: User ID
                          name:
                            type: string
                            description: User full name
                          job_title:
                            type: string
                            nullable: true
                            description: User's job title/role
                          profile_photo_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              avatar placeholder
                          profile_photo_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      picked_by:
                        type: object
                        nullable: true
                        description: User who claimed/picked up this shift from the
                          marketplace (teammate format, present when shift was claimed)
                        properties:
                          id:
                            type: integer
                            description: User ID
                          name:
                            type: string
                            description: User full name
                          job_title:
                            type: string
                            nullable: true
                            description: User's job title/role
                          profile_photo_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              avatar placeholder
                          profile_photo_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      marketplace_info:
                        type: object
                        nullable: true
                        description: Marketplace listing information (present when
                          shift has marketplace listing)
                        properties:
                          has_listing:
                            type: boolean
                            description: Whether this shift has an active marketplace
                              listing
                            example: true
                          listing_id:
                            type: integer
                            description: ID of the marketplace listing
                            example: 880
                          listing_status:
                            type: string
                            enum:
                            - open
                            - filled
                            - closed
                            - cancelled
                            description: Current status of the marketplace listing
                            example: open
                          lister_status:
                            type: string
                            enum:
                            - open
                            - filled
                            - traded
                            - closed
                            - cancelled
                            description: 'Present only when the listing is the caller''s
                              own. The status as its lister reads it: listing_status,
                              except a listing filled by an accepted trade is traded
                              (filled = picked up). Switch on this key, never on lister_status_label.'
                            example: traded
                          lister_status_label:
                            type: string
                            description: Present only when the listing is the caller's
                              own. The word the web and phone pages show the lister
                              (Available, Covered, Traded, Closed, Cancelled), in
                              the language the client asks for (the locale param,
                              else Accept-Language, else the person's own language).
                            example: Traded
                          listing_type:
                            type: string
                            enum:
                            - pickup
                            - trade_only
                            - both
                            description: Type of marketplace listing - pickup (direct
                              claim), trade_only (requires application/trade), or
                              both (accepts either)
                            example: pickup
                          price:
                            type: number
                            format: float
                            description: Price offered for the shift
                            example: 0.01
                          currency:
                            type: string
                            description: Currency code (e.g., USD)
                            example: USD
                          listed_by:
                            type: integer
                            description: User ID of the person who listed the shift
                              (legacy field, use listed_by object instead)
                            example: 1487
                          listed_at:
                            type: string
                            format: date-time
                            description: When the shift was listed in the marketplace
                            example: '2025-12-01T10:05:00Z'
                      notes:
                        type: string
                        nullable: true
                        description: Shift notes (when include=notes)
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                  assignment:
                    type: object
                    description: Present when the shift was assigned at once.
                    properties:
                      id:
                        type: integer
                      shift_id:
                        type: integer
                      user_id:
                        type: integer
                      status:
                        type: string
                        example: assigned
                      assignment_type:
                        type: string
                      scheduled_hours:
                        type: number
                      created_at:
                        type: string
                        format: date-time
                  claim_request:
                    type: object
                    description: Present when the claim waits for an approver.
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        example: pending
                      requested_at:
                        type: string
                        format: date-time
                  warnings:
                    type: array
                    description: What the assignment warned about, when it landed
                      with warnings.
                    items:
                      type: string
        '400':
          description: |
            The shift cannot be claimed. `error.code` says why:
            `already_assigned` (already on the shift, or declined or taken off it),
            `already_requested` (a request for it already waits for approval),
            `shift_not_available` (not open for claiming, or its schedule is not
            published), `not_eligible` (the claimant does not meet the shift's
            eligibility rules, such as its location, team or role),
            or `shift_conflict` (it overlaps one of the claimant's shifts).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            The claim was refused when it was written (`claim_failed`) — for
            example a staffing rule such as an hour limit or a required
            certification. Answered with the record's validation errors, or an
            `Error` with code `claim_failed`.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            description: Field name with validation error
                          message:
                            type: string
                            description: Validation error message
                          code:
                            type: string
                            description: Always `validation_error`
                          field_name:
                            type: string
                            description: |
                              Present only when an error on `base` is about one named field — a
                              survey or form question left blank (`"<label> is required"`). The
                              question's key, as sent in `submission_data`.
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
  "/shifts/{id}/actions":
    post:
      tags:
      - Shifts
      summary: Perform shift-related actions
      description: "Perform various employee actions on shifts:\n- request_coverage:
        Request someone to cover this shift\n- report_lateness: Report that you'll
        be late\n- report_absence: Report absence from shift\n- cancel_request: Close
        your open coverage listing for this shift\n\n**Note:** Use `action_type` parameter
        (preferred) or `action` parameter for the action. \nUsing `action_type` is
        recommended as `action` is a reserved Rails parameter.\n\nScopes: every action
        needs `write:shifts`. `cancel_request` closes your\nlisting, which is delete-level
        (owner decision 2026-10-04): it also needs\n`destructive:shift_marketplace`.\n"
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action_type
              properties:
                action_type:
                  type: string
                  enum:
                  - request_coverage
                  - report_lateness
                  - report_absence
                  - cancel_request
                  description: Type of action to perform (preferred parameter)
                action:
                  type: string
                  enum:
                  - request_coverage
                  - report_lateness
                  - report_absence
                  - cancel_request
                  description: Type of action to perform (legacy, use action_type
                    instead)
                  deprecated: true
                reason:
                  type: string
                  description: Reason for the action
                minutes:
                  type: integer
                  description: Minutes late (for report_lateness)
              examples:
                report_lateness:
                  summary: Report lateness
                  value:
                    action_type: report_lateness
                    minutes: 15
                    reason: Traffic delay
                report_absence:
                  summary: Report absence
                  value:
                    action_type: report_absence
                    reason: Personal appointment
                request_coverage:
                  summary: Request coverage
                  value:
                    action_type: request_coverage
                    reason: Family emergency
      responses:
        '200':
          description: Action completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  reported_at:
                    type: string
                    format: date-time
        '400':
          description: Invalid action or parameters
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_feedbacks":
    get:
      tags:
      - Shifts
      - Feedback
      summary: List user's shift feedbacks
      description: |
        Get all shift feedbacks submitted by the authenticated user.
        Includes feedback details, shift information, and timestamps.

        WITHDRAWN submissions (see `DELETE /shift_feedbacks/{id}`) are excluded
        from both the list and `meta.stats`.
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: shift_id
        in: query
        schema:
          type: integer
        description: Filter by specific shift ID
      - name: include_stats
        in: query
        schema:
          type: boolean
        description: When true, the response `meta` carries a `stats` object for the
          caller's own feedback in this business (total, average rating, and the positive
          / neutral / negative / unrated counts, which sum to the total). These figures
          are always business-wide and are NOT narrowed by `shift_id` — combining
          the two returns one shift's `items` beside the caller's whole history in
          `meta.stats`.
      responses:
        '200':
          description: Shift feedbacks retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 789
                          attendance_record_id:
                            type: integer
                            example: 123
                            description: ID of the associated attendance record
                          user_id:
                            type: integer
                            example: 456
                            description: ID of the user who submitted feedback
                          rating:
                            type: integer
                            minimum: 1
                            maximum: 5
                            example: 4
                            description: Overall shift rating (1-5 stars)
                          feedback_text:
                            type: string
                            nullable: true
                            example: Great shift, but the workspace was a bit noisy.
                            description: Detailed feedback text (optional)
                          shift_difficulty:
                            type: integer
                            minimum: 1
                            maximum: 5
                            nullable: true
                            example: 3
                            description: How difficult was the shift (1=Easy, 5=Very
                              Hard)
                          would_work_again:
                            type: boolean
                            nullable: true
                            example: true
                            description: Would the user work this shift again
                          can_edit:
                            type: boolean
                            example: true
                            description: Whether the feedback can still be edited
                              (within 24 hours)
                          edit_window_expires_at:
                            type: string
                            format: date-time
                            example: '2025-10-11T16:00:00Z'
                            description: When the edit window expires (24 hours from
                              submission)
                          created_at:
                            type: string
                            format: date-time
                            example: '2025-10-10T16:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2025-10-10T16:00:00Z'
                          attendance_record:
                            "$ref": "#/components/schemas/AttendanceRecord"
                          shift:
                            "$ref": "#/components/schemas/Shift"
                          user:
                            "$ref": "#/components/schemas/User"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shifts
      - Feedback
      summary: Submit shift feedback
      description: |
        Submit feedback for a completed shift. Can only submit feedback for shifts that:
        - Have been completed (attendance record exists)
        - User was checked in and out
        - Don't already have feedback submitted

        RE-SUBMITTING AFTER A WITHDRAWAL restores the withdrawn submission in
        place: the response is the usual 201, carrying the SAME `id` as the
        withdrawn one, the newly posted content, and the ORIGINAL
        `submitted_at`. The 24-hour edit/withdraw window therefore runs from the
        FIRST submission and does not restart, and managers are not re-notified.
        Any field you do not send is reset to its default rather than inherited
        from the withdrawn submission. This is the ONLY way back in after a
        withdrawal: `eligible_shifts` and the in-app prompts do not re-offer the
        shift, so a client that withdrew must POST for it explicitly.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - attendance_record_id
              - rating
              properties:
                attendance_record_id:
                  type: integer
                  description: ID of the attendance record for the completed shift
                  example: 123
                rating:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Overall shift rating (1-5 stars)
                  example: 4
                feedback_text:
                  type: string
                  description: Optional detailed feedback text
                  example: Great shift, but the workspace was a bit noisy.
                  maxLength: 2000
                shift_difficulty:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: How difficult was the shift (1=Easy, 5=Very Hard)
                  example: 3
                would_work_again:
                  type: boolean
                  description: Would you work this shift again?
                  example: true
      responses:
        '201':
          description: Shift feedback created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Shift feedback submitted successfully
                  shift_feedback:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      attendance_record_id:
                        type: integer
                        example: 123
                        description: ID of the associated attendance record
                      user_id:
                        type: integer
                        example: 456
                        description: ID of the user who submitted feedback
                      rating:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 4
                        description: Overall shift rating (1-5 stars)
                      feedback_text:
                        type: string
                        nullable: true
                        example: Great shift, but the workspace was a bit noisy.
                        description: Detailed feedback text (optional)
                      shift_difficulty:
                        type: integer
                        minimum: 1
                        maximum: 5
                        nullable: true
                        example: 3
                        description: How difficult was the shift (1=Easy, 5=Very Hard)
                      would_work_again:
                        type: boolean
                        nullable: true
                        example: true
                        description: Would the user work this shift again
                      can_edit:
                        type: boolean
                        example: true
                        description: Whether the feedback can still be edited (within
                          24 hours)
                      edit_window_expires_at:
                        type: string
                        format: date-time
                        example: '2025-10-11T16:00:00Z'
                        description: When the edit window expires (24 hours from submission)
                      created_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      attendance_record:
                        "$ref": "#/components/schemas/AttendanceRecord"
                      shift:
                        "$ref": "#/components/schemas/Shift"
                      user:
                        "$ref": "#/components/schemas/User"
        '400':
          description: Invalid request (missing params, invalid rating, etc.)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Attendance record not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shift_feedbacks/{id}":
    get:
      tags:
      - Shifts
      - Feedback
      summary: Get specific shift feedback
      description: Retrieve details of a specific shift feedback
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Shift feedback ID
      responses:
        '200':
          description: Shift feedback retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  shift_feedback:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      attendance_record_id:
                        type: integer
                        example: 123
                        description: ID of the associated attendance record
                      user_id:
                        type: integer
                        example: 456
                        description: ID of the user who submitted feedback
                      rating:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 4
                        description: Overall shift rating (1-5 stars)
                      feedback_text:
                        type: string
                        nullable: true
                        example: Great shift, but the workspace was a bit noisy.
                        description: Detailed feedback text (optional)
                      shift_difficulty:
                        type: integer
                        minimum: 1
                        maximum: 5
                        nullable: true
                        example: 3
                        description: How difficult was the shift (1=Easy, 5=Very Hard)
                      would_work_again:
                        type: boolean
                        nullable: true
                        example: true
                        description: Would the user work this shift again
                      can_edit:
                        type: boolean
                        example: true
                        description: Whether the feedback can still be edited (within
                          24 hours)
                      edit_window_expires_at:
                        type: string
                        format: date-time
                        example: '2025-10-11T16:00:00Z'
                        description: When the edit window expires (24 hours from submission)
                      created_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      attendance_record:
                        "$ref": "#/components/schemas/AttendanceRecord"
                      shift:
                        "$ref": "#/components/schemas/Shift"
                      user:
                        "$ref": "#/components/schemas/User"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `app_not_enabled` / `app_access_denied` (the tenant has not
            enabled the app, or this user is outside its audience), or
            `insufficient_permissions` (the token lacks `read:shift_feedback`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `feedback_not_found` — no such feedback for the authenticated user
            in this business. A feedback belonging to ANOTHER user answers 404,
            not 403. A WITHDRAWN submission also answers 404.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    patch:
      tags:
      - Shifts
      - Feedback
      summary: Update shift feedback
      description: |
        Update a previously submitted shift feedback.
        Can only update within 24 hours of submission.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Shift feedback ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                rating:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Overall shift rating (1-5 stars)
                feedback_text:
                  type: string
                  description: Detailed feedback text
                  maxLength: 2000
                shift_difficulty:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: How difficult was the shift
                would_work_again:
                  type: boolean
                  description: Would you work this shift again?
      responses:
        '200':
          description: Shift feedback updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Shift feedback updated successfully
                  shift_feedback:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      attendance_record_id:
                        type: integer
                        example: 123
                        description: ID of the associated attendance record
                      user_id:
                        type: integer
                        example: 456
                        description: ID of the user who submitted feedback
                      rating:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 4
                        description: Overall shift rating (1-5 stars)
                      feedback_text:
                        type: string
                        nullable: true
                        example: Great shift, but the workspace was a bit noisy.
                        description: Detailed feedback text (optional)
                      shift_difficulty:
                        type: integer
                        minimum: 1
                        maximum: 5
                        nullable: true
                        example: 3
                        description: How difficult was the shift (1=Easy, 5=Very Hard)
                      would_work_again:
                        type: boolean
                        nullable: true
                        example: true
                        description: Would the user work this shift again
                      can_edit:
                        type: boolean
                        example: true
                        description: Whether the feedback can still be edited (within
                          24 hours)
                      edit_window_expires_at:
                        type: string
                        format: date-time
                        example: '2025-10-11T16:00:00Z'
                        description: When the edit window expires (24 hours from submission)
                      created_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2025-10-10T16:00:00Z'
                      attendance_record:
                        "$ref": "#/components/schemas/AttendanceRecord"
                      shift:
                        "$ref": "#/components/schemas/Shift"
                      user:
                        "$ref": "#/components/schemas/User"
        '400':
          description: |
            `parameter_missing` — the request body is missing the
            `shift_feedback` object, or sends a non-scalar value for a scalar
            field. The expired edit window is a 403, not a 400.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `update_not_allowed` (more than 24 hours since
            submission), `feedback_collection_disabled`, `app_not_enabled` /
            `app_access_denied`, or `insufficient_permissions` (the token lacks
            `write:shift_feedback`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `feedback_not_found` — no such feedback for the authenticated user
            in this business. A feedback belonging to ANOTHER user answers 404,
            not 403.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Shifts
      - Feedback
      summary: Withdraw shift feedback
      description: |
        Withdraw a previously submitted shift feedback.
        Can only withdraw within 24 hours of submission.

        The submission is RETAINED, not erased: it is marked withdrawn and
        disappears from every read surface (this API's list and detail
        endpoints, the in-app feedback lists and CSV exports, search, reporting
        and the engagement signal it fed), and it stops counting toward the
        tenant's response rate. Nothing about the request or the response
        changes — this note exists so integrators do not treat a 200 here as
        proof the data is gone.

        Re-submitting feedback for the same attendance record afterwards
        RESTORES the withdrawn submission in place: `POST /shift_feedbacks`
        returns 201 with the SAME `id`, carrying the newly posted content and
        the ORIGINAL `submitted_at` — so the 24-hour edit/withdraw window runs
        from the first submission and does not restart.

        The shift is NOT put back on `GET /shift_feedbacks/eligible_shifts`, and
        it is not re-offered on any in-app "rate your shift" prompt. A withdrawal
        is a decision the app records, so re-submitting is a deliberate POST by
        the client that withdrew — not something the worker is prompted for
        again.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Shift feedback ID
      responses:
        '200':
          description: Shift feedback withdrawn successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Feedback withdrawn
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            Refused. `deletion_not_allowed` (more than 24 hours since
            submission), `feedback_collection_disabled` (the tenant has turned
            Shift Feedback collection off), `app_not_enabled` /
            `app_access_denied` (the tenant has not enabled the app, or this
            user is outside its audience), or `insufficient_permissions`
            (the token lacks `write:shift_feedback`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `feedback_not_found` — no such feedback for the authenticated user
            in this business. A feedback belonging to ANOTHER user answers 404,
            not 403: the lookup is scoped to the caller's own submissions and
            never discloses that the id exists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_feedbacks/eligible_shifts":
    get:
      tags:
      - Shifts
      - Feedback
      summary: Get shifts eligible for feedback
      description: |
        Get a list of completed shifts that are eligible for feedback submission.
        Only returns shifts that:
        - Have been completed (user was checked in and out)
        - Don't already have feedback submitted — a WITHDRAWN submission still
          counts as submitted here, so a shift whose feedback was withdrawn is
          not re-offered
        - Belong to the authenticated user

        Returns the tenant's configured number of most recent eligible shifts
        (the `max_feedback_shifts` setting: 3 by default, 10 at most).
      responses:
        '200':
          description: Eligible shifts retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  eligible_shifts:
                    type: array
                    items:
                      type: object
                      properties:
                        attendance_record_id:
                          type: integer
                          example: 123
                        shift_id:
                          type: integer
                          example: 456
                        shift_name:
                          type: string
                          example: Morning Shift - Location A
                        shift_date:
                          type: string
                          format: date
                          example: '2025-10-09'
                        shift_start_time:
                          type: string
                          format: date-time
                          example: '2025-10-09T08:00:00Z'
                        check_in_time:
                          type: string
                          format: date-time
                          example: '2025-10-09T08:05:00Z'
                        check_out_time:
                          type: string
                          format: date-time
                          example: '2025-10-09T16:00:00Z'
                        hours_worked:
                          type: number
                          format: float
                          example: 7.92
                  count:
                    type: integer
                    description: Number of eligible shifts
                    example: 3
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/missing_punch_requests":
    get:
      tags:
      - Attendance
      summary: List the caller's missing-punch requests
      description: |
        The authenticated employee's own punch-correction requests, newest first.

        Deliberately self-scoped — this is the employee surface. The manager
        review queue is `GET /api/v1/attendance_records/requires_review`, which
        carries its own role gate.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
        description: "`pending` returns only requests still awaiting review — the
          set the caller can still cancel. Any other value is ignored rather than
          rejected, so new values can be added without breaking old clients.\n"
      responses:
        '200':
          description: Requests retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: 'An employee-submitted punch correction. Stored
                          as an AttendanceRecord with punch_source=''missing_punch_request''
                          and flagged for manager review — it is a REQUEST, not an
                          approved punch, until a manager acts on it.

                          '
                        properties:
                          id:
                            type: integer
                          status:
                            type: string
                            description: "`pending_review` until a manager acts; afterwards
                              the underlying attendance status.\n"
                          pending_review:
                            type: boolean
                          check_in_time:
                            type: string
                            format: date-time
                          check_out_time:
                            type: string
                            format: date-time
                          reason:
                            type: string
                            description: The employee's stated reason for the correction.
                          reviewed_at:
                            type: string
                            format: date-time
                            nullable: true
                          created_at:
                            type: string
                            format: date-time
                          can_cancel:
                            type: boolean
                            description: 'Whether DELETE would succeed — true only
                              while no manager has acted. Use it to decide whether
                              to show a cancel control instead of discovering the
                              rule from a 422.

                              '
                          shift_id:
                            type: integer
                            description: Detailed responses only (create). The supporting
                              ad-hoc shift.
                          location_name:
                            type: string
                            nullable: true
                            description: Detailed responses only.
                          breaks:
                            type: array
                            description: 'Detailed responses only. Required break
                              types the employee did not submit appear with status
                              `skipped`.

                              '
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                break_type_id:
                                  type: integer
                                break_type_name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                  nullable: true
                                end_time:
                                  type: string
                                  format: date-time
                                  nullable: true
                                duration_minutes:
                                  type: integer
                                  nullable: true
                                status:
                                  type: string
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
    post:
      tags:
      - Attendance
      summary: Submit a missing-punch request
      description: |
        File a punch correction for a day the clock missed. Creates an
        AttendanceRecord flagged for manager review (it is NOT an approved
        punch), plus a supporting ad-hoc shift, and notifies the managers who
        have to review it.

        All rules are shared with the web form — reason bounds, maximum span,
        the retroactive window, and break-row validation. Call
        `GET /missing_punch_requests/window` first and build the picker from
        those bounds so the client cannot offer a date this endpoint refuses.

        Times are parsed server-side: an unparseable value is refused, never
        cast to null.

        Submitting twice files once. A second submission whose hours overlap a
        request the caller already has pending — or, for a direct entry, a day
        the caller already added — is refused with 422 `validation_failed`.
        Send ONE `Idempotency-Key` per form (mint it when the form opens, not on
        each tap): a repeat carrying the same key replays the first 201
        (`X-Idempotency-Cached: true`) instead of answering 422, so a double
        tap still lands on the success screen.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - check_in_time
              - check_out_time
              - reason
              properties:
                check_in_time:
                  type: string
                  format: date-time
                  description: Must be in the past and inside the allowed window.
                check_out_time:
                  type: string
                  format: date-time
                  description: 'Must be after check_in_time. May fall on the following
                    day — overnights are allowed — but the total span is capped (see
                    max_span_hours on the window endpoint).

                    '
                reason:
                  type: string
                  description: Why the punch is missing. Length bounds come from the
                    window endpoint.
                timesheet_id:
                  type: integer
                  description: 'Optional. Narrows the allowed window to that timesheet''s
                    pay period. An id that is not the caller''s own is ignored rather
                    than borrowing another employee''s period.

                    '
                breaks:
                  type: array
                  description: 'Optional break rows. Each must sit inside the punch
                    window and carry both times or neither. Required break types the
                    caller omits are recorded as skipped.

                    '
                  items:
                    type: object
                    properties:
                      break_type_id:
                        type: integer
                      start_time:
                        type: string
                        format: date-time
                      end_time:
                        type: string
                        format: date-time
      responses:
        '201':
          description: 'Created. For a caller whose role carries the Time & Attendance
            grant for their own records (`direct_entry: true`) the day is ADDED to
            their timesheet outright — reviewed on creation, no review queue, nobody
            notified; for everyone else it is a request pending manager review. Read
            `GET /missing_punch_requests/window` first: its `direct_entry` says which
            of the two this caller gets, so the form can promise the right thing.

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  missing_punch_request:
                    type: object
                    description: 'An employee-submitted punch correction. Stored as
                      an AttendanceRecord with punch_source=''missing_punch_request''
                      and flagged for manager review — it is a REQUEST, not an approved
                      punch, until a manager acts on it.

                      '
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        description: "`pending_review` until a manager acts; afterwards
                          the underlying attendance status.\n"
                      pending_review:
                        type: boolean
                      check_in_time:
                        type: string
                        format: date-time
                      check_out_time:
                        type: string
                        format: date-time
                      reason:
                        type: string
                        description: The employee's stated reason for the correction.
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      can_cancel:
                        type: boolean
                        description: 'Whether DELETE would succeed — true only while
                          no manager has acted. Use it to decide whether to show a
                          cancel control instead of discovering the rule from a 422.

                          '
                      shift_id:
                        type: integer
                        description: Detailed responses only (create). The supporting
                          ad-hoc shift.
                      location_name:
                        type: string
                        nullable: true
                        description: Detailed responses only.
                      breaks:
                        type: array
                        description: 'Detailed responses only. Required break types
                          the employee did not submit appear with status `skipped`.

                          '
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            break_type_id:
                              type: integer
                            break_type_name:
                              type: string
                            start_time:
                              type: string
                              format: date-time
                              nullable: true
                            end_time:
                              type: string
                              format: date-time
                              nullable: true
                            duration_minutes:
                              type: integer
                              nullable: true
                            status:
                              type: string
                  direct_entry:
                    type: boolean
                    description: 'TRUE when the caller''s role carries the Time &
                      Attendance grant for their own records: the record was written
                      reviewed and is already on their timesheet, and `managers_notified`
                      is false because there was nothing to review. Word the confirmation
                      as "added to your timesheet", not "sent to your manager".

                      '
                  managers_notified:
                    type: boolean
                    description: 'Whether any manager was actually reached. FALSE
                      is a real outcome — a location supervised only by an admin can
                      resolve no manager recipient — so do not tell the employee their
                      request was sent to a manager unless this is true.

                      '
                  notification_status:
                    type: string
                    enum:
                    - notified
                    - no_recipients
                    - delivery_failed
                    - not_needed
                    description: 'Why `managers_notified` is what it is (`not_needed`
                      when `direct_entry` is true — nobody reviews a day the caller
                      added under their own grant). Prefer this over the boolean when
                      wording the confirmation, because `no_recipients` and `delivery_failed`
                      need DIFFERENT copy: `no_recipients` means no manager is assigned
                      to the location (tell them to contact HR), while `delivery_failed`
                      means the request IS in their manager''s review queue and only
                      the alert was lost (do not send them to HR). The request itself
                      is created and pending review in all three cases.

                      '
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
        '422':
          description: 'Validation failure. `error.code` is `validation_failed` for
            input the employee can fix (reason length, span, window, break rows, hours
            already pending or already added) or `record_invalid` when the record
            itself was rejected. `error.message` is the sentence to show them.

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/missing_punch_requests/window":
    get:
      tags:
      - Attendance
      summary: Bounds and limits for building the missing-punch form
      description: |
        Everything a client needs to render the form so it cannot offer a value
        the submit endpoint refuses: the allowed datetime range, whether the
        window is open at all, the reason/span limits, and the required break
        rows. Read off the same object that validates the submit.
      security:
      - BearerAuth: []
      parameters:
      - name: timesheet_id
        in: query
        schema:
          type: integer
        description: Narrow the window to that timesheet's pay period.
      responses:
        '200':
          description: Window resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  window:
                    type: object
                    properties:
                      open:
                        type: boolean
                        description: 'FALSE when the pay period and the retroactive
                          window do not overlap — there is no date the submit endpoint
                          would accept. Withhold the form and show closed_reason.

                          '
                      min_time:
                        type: string
                        format: date-time
                        nullable: true
                        description: Earliest allowed clock-in. Null means no lower
                          bound.
                      max_time:
                        type: string
                        format: date-time
                        description: Latest allowed clock-in (never in the future).
                      check_out_max_time:
                        type: string
                        format: date-time
                        description: 'Ceiling for clock-OUT, deliberately later than
                          max_time so a shift starting on the period''s last day can
                          end the next morning.

                          '
                      scoped_to_timesheet:
                        type: boolean
                      timesheet_period:
                        type: string
                        nullable: true
                      lockout_days:
                        type: integer
                        description: 'The retroactive correction window that applies
                          to THIS caller, in days — the tenant setting for a request,
                          0 when `direct_entry` is true (a grant holder is bounded
                          only by the pay period and "not in the future").

                          '
                      direct_entry:
                        type: boolean
                        description: 'TRUE when submitting will ADD the day to the
                          caller''s timesheet outright (their role carries Create
                          or Manage on Time & Attendance, the "Own data" half of the
                          grant); FALSE when it FILES a request a manager reviews.
                          Switch the form''s copy on it.

                          '
                      range_sentence:
                        type: string
                        nullable: true
                        description: 'The allowed range in one sentence, shared with
                          the web form''s helper text and the server''s rejection
                          so no surface states a different rule. NULL when open is
                          false — a closed window''s minimum is later than its maximum,
                          so there is no coherent range to state; use closed_reason
                          instead.

                          '
                      closed_reason:
                        type: string
                        nullable: true
                        description: Why the window is closed. Null when open.
                      reason_min_length:
                        type: integer
                      reason_max_length:
                        type: integer
                      max_span_hours:
                        type: integer
                      required_break_types:
                        type: array
                        description: 'Required break types only — an optional break
                          the employee did not take needs no row.

                          '
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            duration_minutes:
                              type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
  "/missing_punch_requests/{id}":
    delete:
      tags:
      - Attendance
      summary: Withdraw a pending missing-punch request
      description: |
        Cancel a request no manager has acted on yet, removing the supporting
        ad-hoc shift with it. A request belonging to another employee, or one
        already reviewed, is refused — use `can_cancel` on the request to decide
        whether to show the control.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:attendance` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Request cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                  id:
                    type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Time & Attendance is not enabled or not accessible to this
            user
        '409':
          description: "`cancel_blocked` — other records (lone-worker check-ins or
            alerts, attendance notifications) still reference this request, so it
            can never be cancelled by retrying. Ask a manager to reject it instead.\n"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`not_cancellable` — already reviewed, not a missing-punch
            request, or not the caller's own.\n"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/break_types":
    get:
      tags:
      - Attendance
      summary: List break types
      description: |
        The tenant's break types, for a break picker. Without this, a client can
        only see breaks already planned on the current shift (the
        `break_records`/`break_type` embeds on the attendance record), so a
        tenant whose optional break types are not attached to shifts shows the
        employee an empty break list.

        The field set matches the `break_type` embed on the attendance record
        exactly, so one client-side model decodes both. Required types are
        included rather than filtered out — `is_required` is present precisely
        so the client can split required-and-auto-attached from
        optional-and-startable itself.

        Not paginated: a tenant's break-type count is naturally small and a
        picker needs the full set.
      responses:
        '200':
          description: The tenant's break types, ordered by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  break_types:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 5
                        name:
                          type: string
                          example: Meal Break
                        duration_minutes:
                          type: integer
                          example: 30
                        is_required:
                          type: boolean
                          example: true
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                        example: 4
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/break_attestations/{id}/answer":
    post:
      tags:
      - Attendance
      summary: Answer a rest-break question put off to this clock-in
      description: |
        Records the caller's own answer to a rest-break question that was not asked
        when their earlier shift closed (a manager, the shared kiosk or the automatic
        clock-out closed it). The questions that wait are the
        `rest_break_confirmations` of `POST /attendance_records/{id}/check_in`,
        the verified `POST /attendance_records` and
        `GET /attendance_records/status`. The caller
        must be clocked in. The question of a punch that is still open is answered
        with its own clock-out (`break_attestation` on `…/check_out`), not here.
        Needs `write:attendance` (or the employee's own `write:own_attendance`).
        A replay of an answer already on file answers 200 with `changed: false`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - break_attestation
              properties:
                break_attestation:
                  type: object
                  properties:
                    all_rest_breaks_taken:
                      type: string
                      enum:
                      - 'yes'
                      - 'no'
                    missed_or_short_count:
                      type: string
                      description: How many rest breaks were missed or short, when
                        `all_rest_breaks_taken` is `no`.
                      example: '2'
      responses:
        '200':
          description: Recorded (or already on file — `changed` is false then).
          content:
            application/json:
              schema:
                type: object
                properties:
                  changed:
                    type: boolean
                  break_attestation:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        enum:
                        - confirmed
                        - disputed
                        - pending
                        - deferred
                      missed_break_count:
                        type: integer
                        nullable: true
                      outcome:
                        type: string
                        example: All rest breaks taken
                  rest_break_confirmations:
                    type: array
                    description: What still waits for the caller after this answer.
                    items:
                      "$ref": "#/components/schemas/RestBreakConfirmation"
        '403':
          description: |
            `error.code` = `not_your_record` — the question is someone else's;
            `own_act_only` — a service account acting as the person, or an admin's
            View-as, cannot make this certification; or `insufficient_permissions`
            — the token lacks `write:attendance`. Time & Attendance switched off for
            the organization, or not open to the caller, also answers 403, as every
            attendance endpoint does.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`error.code` = `not_found` — no such question in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            `error.code` = `not_waiting` — the question is not waiting to be asked
            (not outstanding, the shift's punch is still open, or it is past the
            14-day window); `not_clocked_in` — clock in first; `answer_required` —
            no answer chosen; `invalid_answer` — the count is out of range.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records":
    get:
      tags:
      - Attendance
      summary: List attendance records
      description: |
        Returns a paginated list of attendance records with optional filters for user, shift, and status.
        Use this to retrieve historical attendance activity for reporting and audits.
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: user_id
        in: query
        schema:
          type: integer
      - name: shift_id
        in: query
        schema:
          type: integer
      - name: status
        in: query
        description: Filter by attendance status. Comma-separated values are accepted.
          These are the real AttendanceRecord statuses; `approved`/`rejected` were
          previously documented here but are review outcomes on a different column,
          so requesting them returned an empty list forever.
        schema:
          type: string
          enum:
          - pending
          - on_time
          - late
          - missed
          - completed
          - absence_reported
      responses:
        '200':
          description: List of attendance records
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          managed_by:
                            type: string
                            nullable: true
                            example: UKG
                            description: The workforce management system of record
                              whose punch this is, or null. A managed punch (and its
                              breaks) is read-only here.
                          id:
                            type: integer
                          user_id:
                            type: integer
                          shift_id:
                            type: integer
                          business_id:
                            type: integer
                          check_in_time:
                            type: string
                            format: date-time
                            nullable: true
                          check_out_time:
                            type: string
                            format: date-time
                            nullable: true
                          status:
                            type: string
                            enum:
                            - pending
                            - on_time
                            - late
                            - missed
                            - completed
                            - absence_reported
                            - excused
                            - no_show
                          location_verified:
                            type: boolean
                            default: false
                          photo_verification_required:
                            type: boolean
                            default: false
                          photo_verification_failed_reason:
                            type: string
                            nullable: true
                          is_unknown_device:
                            type: boolean
                            default: false
                          requires_review:
                            type: boolean
                            default: false
                          review_reason:
                            type: string
                            nullable: true
                          review_reason_label:
                            type: string
                            nullable: true
                            description: The words people see for `review_reason`
                              ("Missing-punch request" for `missing_punch_request`);
                              null when there is no reason.
                          reviewed_by_user_id:
                            type: integer
                            nullable: true
                          reviewed_at:
                            type: string
                            format: date-time
                            nullable: true
                          excused_by_user_id:
                            type: integer
                            nullable: true
                          excused_at:
                            type: string
                            format: date-time
                            nullable: true
                          absence_report_id:
                            type: integer
                            nullable: true
                          notes:
                            type: string
                            nullable: true
                          created_at:
                            type: string
                            format: date-time
                          updated_at:
                            type: string
                            format: date-time
                          user:
                            "$ref": "#/components/schemas/User"
                          shift:
                            allOf:
                            - "$ref": "#/components/schemas/Shift"
                            nullable: true
                            description: 'The record''s shift. A SUBSET of the Shift
                              schema: id, name, start_time, end_time, formatted_date,
                              formatted_time — plus status, location and location_department
                              when `include=shift_details` is requested. `formatted_date`
                              ("Wednesday, Aug 05") and `formatted_time` ("01:30 PM
                              - 09:30 PM") are rendered in the SHIFT''s own timezone,
                              are byte-identical to what `GET /shifts` returns for
                              the same shift.id, and do not vary with the caller''s
                              profile timezone — clients should prefer them over formatting
                              `start_time` locally, which has no usable zone once
                              an SDK decodes it to a bare instant. `formatted_date`
                              always names the shift''s START day, so it is correct
                              for a shift crossing midnight.'
                          break_records:
                            type: array
                            items:
                              "$ref": "#/components/schemas/BreakRecord"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
    post:
      tags:
      - Attendance
      summary: Create attendance record (check-in)
      description: |
        Creates a new attendance record representing a check-in event. If `shift_id` is omitted or null,
        the system creates an adhoc shift at the user's primary location and associates the record automatically.

        **Adhoc Clock-in**: When shift_id is null, the system will:
        1. Resolve the location — `location_id` when supplied, otherwise the
           user's primary assigned location, else their assigned active
           locations by name, else the business's active locations by name
        2. Create an adhoc shift with no predetermined end time
        3. Create a shift assignment for the user
        4. Create the attendance record with the new shift_id

        Supply `location_id` to let the employee choose where they are punching
        in; `GET /locations` returns exactly the set that is accepted. A
        `location_id` outside that set is REFUSED with 422 rather than being
        ignored — a chooser whose choice is silently discarded would land the
        punch at a site the employee did not pick.

        **Your own punch is always a verified clock-in at the current time.**
        When the record is for the caller (`user_id` omitted or the caller's
        own) and the request is made with the person's own sign-in — not a
        service-account token or an impersonation minted by one — the punch
        always runs the verification pipeline (photo, geofence, IP, device,
        time window, review flagging) as `verify: true` does, and is stamped
        with the server's time: a `check_in_time` sent with it is ignored, and
        an ad-hoc shift starts now. A `check_out_time` is refused (403), and a
        `shift_id` must be a shift the caller is assigned to (422
        `shift_not_held`). Time already worked, or a punch that was missed, is
        reported through `POST /missing_punch_requests`, which a manager
        reviews.

        A manager recording a punch for someone they manage, and a service
        account, may send `check_in_time` (required unless `verify: true`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                  description: ID of the user checking in. Omit, or send your own
                    id, to clock yourself in.
                shift_id:
                  type: integer
                  nullable: true
                  description: ID of the shift to check in for. If null, an adhoc
                    shift will be created automatically. On your own punch it must
                    be a shift you are assigned to.
                check_in_time:
                  type: string
                  format: date-time
                  description: 'Time of check-in. Required when a manager or a service
                    account records someone''s punch without `verify: true`. Ignored
                    on your own punch, which is stamped with the server''s time.'
                verify:
                  type: boolean
                  description: Run the verification pipeline and stamp the current
                    time. Always on for your own punch.
                location_id:
                  type: integer
                  description: Adhoc clock-in only (ignored when shift_id is supplied
                    — a scheduled punch takes its shift's location). Must be one of
                    the caller's assigned ACTIVE locations for this business; anything
                    else is refused with 422. Omit to accept the resolved default
                    described above. Feed the picker from `GET /locations`.
                location:
                  type: string
                  description: Location description
                device_info:
                  type: string
                  description: Device information
                notes:
                  type: string
                  description: Additional notes
      responses:
        '201':
          description: |
            Attendance record created. The record is wrapped in
            `attendance_record`, the same envelope GET /attendance_records/{id}
            answers with; a `verify: true` punch also carries `notification_sent`.
          content:
            application/json:
              schema:
                type: object
                required:
                - attendance_record
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  notification_sent:
                    type: boolean
                    description: 'Present on a `verify: true` punch only.'
        '422':
          description: |
            Validation failed. For adhoc clock-in, `errors[].field` =
            `location_id` means either the supplied location is not one of the
            caller's assigned active locations, or none was supplied and the
            user has no assignable location to fall back to. `error.code` =
            `shift_not_held` — your own punch named a shift you are not
            assigned to; report time on another shift through
            `POST /missing_punch_requests`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: |
            Refused. Either the caller may not create a record for the named
            employee, or `check_out_time` was sent (`insufficient_permissions`:
            your own punch never arrives closed, and someone else's needs a
            manager), or — `error.code` = `kiosk_mode_enabled` — the business
            runs a shared Time Clock Kiosk and self-service punching from a
            personal device is turned off. The kiosk refusal carries
            `error.details.time_clock_kiosk_enabled: true` and applies only when
            the record is for the caller themselves; a manager recording a punch
            for a subordinate is unaffected. Not retryable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/check_out":
    post:
      tags:
      - Attendance
      summary: Check out from shift
      description: |
        Completes an attendance record by recording a check-out time and optional metadata such as notes
        and location. Returns the updated attendance record.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                check_out_time:
                  type: string
                  format: date-time
                location:
                  type: string
                notes:
                  type: string
                close_at_shift_end:
                  type: boolean
                  description: |
                    Record the clock-out at the shift's end instead of now, flagged for the
                    approver to review. Offered when clocking was paused (a freeze, lockdown,
                    suspension or maintenance) at the shift's end and has since resumed —
                    `GET /attendance_records/status` names that time in
                    `shift_end_clock_out_at` — and for a punch forgotten past its shift.
                    Without it the clock-out is recorded when it is sent.
      responses:
        '200':
          description: |
            Successfully checked out. Alongside `attendance_record`, the body
            carries an `undo` object describing the Undo Clock-Out window that
            just opened — render the countdown from `undo_seconds_remaining`
            (measured against the server clock, so a device with clock drift
            still counts down correctly) and POST
            `/attendance_records/{id}/undo_clock_out` while it is above zero.
            `GET /attendance_records/status` carries the same `undo` object, so
            a client that relaunches inside the window can rebuild the card.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  undo:
                    "$ref": "#/components/schemas/UndoClockOutWindow"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so clocking out from a personal device is turned off.
            Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable — the
            employee must clock out at the kiosk.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/undo_clock_out":
    post:
      tags:
      - Attendance
      summary: Undo a clock-out
      description: |
        Reverses the CALLER'S OWN clock-out within the 15-minute undo window,
        reopening the punch so they can keep working. Self-service only: a
        manager correcting someone else's punch uses
        `PATCH /attendance_records/{id}/adjust`, which is audited as a manager
        edit. Refused once the caller has clocked in again.

        Send an `Idempotency-Key` header to make a network retry safe — a replay
        returns the original response with `X-Idempotency-Cached: true` rather
        than failing as an expired window.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: |
            Clock-out undone; the caller is clocked back in. `undo` comes back
            with `can_undo_clock_out: false` because the punch is open again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  undo:
                    "$ref": "#/components/schemas/UndoClockOutWindow"
                  message:
                    type: string
        '403':
          description: |
            `error.code` = `not_your_record` — the record belongs to someone
            else. Or `kiosk_mode_enabled` — the business runs a shared Time Clock
            Kiosk, so reversing a punch from a personal device is turned off.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            `error.code` = `undo_window_expired` — the window has passed, the
            record is not a completed clock-out, or shift feedback was already
            submitted; `error.details` carries the same `undo` fields so the
            client can re-render the card. Or `already_clocked_in` — the caller
            has since started another punch (`error.details.open_attendance_record_id`).
            Or `undo_clock_out_failed` — the reversal was refused on save.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}":
    get:
      tags:
      - Attendance
      summary: Get attendance record details
      description: Retrieve a single attendance record by ID, including times and
        status.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Attendance record details
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
    put:
      tags:
      - Attendance
      summary: Update attendance record
      description: |
        Update an existing attendance record's times, status, or notes. Changing a
        check_in_time or check_out_time requires `edit_reason` (422
        `edit_reason_required` without it); it is recorded in the edit history.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                check_in_time:
                  type: string
                  format: date-time
                check_out_time:
                  type: string
                  format: date-time
                status:
                  type: string
                  enum:
                  - pending
                  - on_time
                  - late
                  - missed
                  - completed
                  - absence_reported
                  - excused
                  - no_show
                notes:
                  type: string
                edit_reason:
                  type: string
                  maxLength: 500
                  description: Why the punch time changed. Required when a check_in_time
                    or check_out_time changes.
      responses:
        '200':
          description: Attendance record updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  timesheet_warning:
                    type: string
                    nullable: true
                    description: Set when the new times were saved on the attendance
                      record but NOT applied to the employee's timesheet, because
                      that timesheet is already approved — the sentence says what
                      to do (return it for correction, or, for your own punch, use
                      Request Correction). Null when the timesheet was updated or
                      there is none.
                    example: Their approved timesheet was not changed — return it
                      for correction to apply the new times.
    delete:
      tags:
      - Attendance
      summary: Delete attendance record
      description: |
        Permanently deletes an attendance record. Admin-only in most environments.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:attendance` scope.

        When the punch sat on the employee's APPROVED timesheet, the record is
        deleted but the timesheet keeps its hours (an approved wage record is
        never rewritten); the answer is then 200 with `timesheet_warning`
        instead of 204. When someone other than the employee deletes it, the
        employee gets an in-app notice.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Attendance record deleted, but the employee's approved timesheet
            still has its hours.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet_warning:
                    type: string
                    description: What to do about the hours the approved timesheet
                      kept (return it for correction, or, for your own punch, use
                      Request Correction).
                    example: Their approved timesheet still has the deleted punch's
                      hours — return it for correction to remove them.
                  unread_notification_count:
                    type: integer
                    nullable: true
                    description: The caller's unread notification count, carried on
                      every JSON answer (null without the read:notifications scope).
        '204':
          description: Attendance record deleted successfully, and no approved timesheet
            kept its hours
  "/attendance_records/{id}/mark_for_review":
    post:
      tags:
      - Attendance
      summary: Mark attendance record for review
      description: Flags an attendance record for manager review due to potential
        issues.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Record marked for review
          content:
            application/json:
              schema:
                type: object
                properties:
                  managed_by:
                    type: string
                    nullable: true
                    example: UKG
                    description: The workforce management system of record whose punch
                      this is, or null. A managed punch (and its breaks) is read-only
                      here.
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  review_reason_label:
                    type: string
                    nullable: true
                    description: The words people see for `review_reason` ("Missing-punch
                      request" for `missing_punch_request`); null when there is no
                      reason.
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/approve":
    post:
      tags:
      - Attendance
      summary: Approve attendance record
      description: Marks an attendance record as approved following review.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Record approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  managed_by:
                    type: string
                    nullable: true
                    example: UKG
                    description: The workforce management system of record whose punch
                      this is, or null. A managed punch (and its breaks) is read-only
                      here.
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  review_reason_label:
                    type: string
                    nullable: true
                    description: The words people see for `review_reason` ("Missing-punch
                      request" for `missing_punch_request`); null when there is no
                      reason.
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/reject":
    post:
      tags:
      - Attendance
      summary: Reject attendance record
      description: |
        Marks an attendance record as rejected following review, preserving audit trail.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:attendance` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Record rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  managed_by:
                    type: string
                    nullable: true
                    example: UKG
                    description: The workforce management system of record whose punch
                      this is, or null. A managed punch (and its breaks) is read-only
                      here.
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  review_reason_label:
                    type: string
                    nullable: true
                    description: The words people see for `review_reason` ("Missing-punch
                      request" for `missing_punch_request`); null when there is no
                      reason.
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/{id}/adjust":
    patch:
      tags:
      - Attendance
      summary: Adjust attendance record times
      description: |
        Adjusts check-in/check-out times, break duration, and notes for the record.
        `notes` is the reason for the change and is required (422 when blank); it is
        recorded in the edit history and shown to the employee.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                attendance_record:
                  type: object
                  properties:
                    check_in_time:
                      type: string
                      format: date-time
                    check_out_time:
                      type: string
                      format: date-time
                    break_duration:
                      type: integer
                    notes:
                      type: string
                    adjustment_reason:
                      type: string
      responses:
        '200':
          description: Record adjusted
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  notification_sent:
                    type: boolean
                    description: Whether the employee was told about the adjustment
                  timesheet_warning:
                    type: string
                    nullable: true
                    description: Set when the new times were saved on the attendance
                      record but NOT applied to the employee's timesheet, because
                      that timesheet is already approved — the sentence says what
                      to do (return it for correction, or, for your own punch, use
                      Request Correction). Null when the timesheet was updated or
                      there is none.
                    example: Their approved timesheet was not changed — return it
                      for correction to apply the new times.
  "/attendance_records/{id}/check_in":
    post:
      tags:
      - Attendance
      summary: Check in to shift
      description: Records a check-in event on an existing attendance record with
        optional verification.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                latitude:
                  type: number
                  format: float
                  description: GPS latitude for location verification
                longitude:
                  type: number
                  format: float
                  description: GPS longitude for location verification
                device_fingerprint:
                  type: string
                  description: Device identification for verification
                photo:
                  type: string
                  format: binary
                  description: Photo for verification (if required)
      responses:
        '200':
          description: |
            Successfully checked in. `clock_in_brief_path` is where to fetch
            "Before you start" for this punch next —
            `GET /attendance_records/{id}/clock_in_brief`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  attendance_record:
                    type: object
                    properties:
                      managed_by:
                        type: string
                        nullable: true
                        example: UKG
                        description: The workforce management system of record whose
                          punch this is, or null. A managed punch (and its breaks)
                          is read-only here.
                      id:
                        type: integer
                      user_id:
                        type: integer
                      shift_id:
                        type: integer
                      business_id:
                        type: integer
                      check_in_time:
                        type: string
                        format: date-time
                        nullable: true
                      check_out_time:
                        type: string
                        format: date-time
                        nullable: true
                      status:
                        type: string
                        enum:
                        - pending
                        - on_time
                        - late
                        - missed
                        - completed
                        - absence_reported
                        - excused
                        - no_show
                      location_verified:
                        type: boolean
                        default: false
                      photo_verification_required:
                        type: boolean
                        default: false
                      photo_verification_failed_reason:
                        type: string
                        nullable: true
                      is_unknown_device:
                        type: boolean
                        default: false
                      requires_review:
                        type: boolean
                        default: false
                      review_reason:
                        type: string
                        nullable: true
                      review_reason_label:
                        type: string
                        nullable: true
                        description: The words people see for `review_reason` ("Missing-punch
                          request" for `missing_punch_request`); null when there is
                          no reason.
                      reviewed_by_user_id:
                        type: integer
                        nullable: true
                      reviewed_at:
                        type: string
                        format: date-time
                        nullable: true
                      excused_by_user_id:
                        type: integer
                        nullable: true
                      excused_at:
                        type: string
                        format: date-time
                        nullable: true
                      absence_report_id:
                        type: integer
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      user:
                        "$ref": "#/components/schemas/User"
                      shift:
                        allOf:
                        - "$ref": "#/components/schemas/Shift"
                        nullable: true
                        description: 'The record''s shift. A SUBSET of the Shift schema:
                          id, name, start_time, end_time, formatted_date, formatted_time
                          — plus status, location and location_department when `include=shift_details`
                          is requested. `formatted_date` ("Wednesday, Aug 05") and
                          `formatted_time` ("01:30 PM - 09:30 PM") are rendered in
                          the SHIFT''s own timezone, are byte-identical to what `GET
                          /shifts` returns for the same shift.id, and do not vary
                          with the caller''s profile timezone — clients should prefer
                          them over formatting `start_time` locally, which has no
                          usable zone once an SDK decodes it to a bare instant. `formatted_date`
                          always names the shift''s START day, so it is correct for
                          a shift crossing midnight.'
                      break_records:
                        type: array
                        items:
                          "$ref": "#/components/schemas/BreakRecord"
                  notification_sent:
                    type: boolean
                  clock_in_brief_path:
                    type: string
                    example: "/api/v1/attendance_records/123/clock_in_brief"
                  rest_break_confirmations:
                    type: array
                    description: |
                      Rest-break questions put off to this clock-in (a manager, the
                      shared kiosk or the automatic clock-out closed an earlier
                      shift without asking). Empty unless the business enforces
                      rest-break confirmation. Ask each one and post the answer to
                      `POST /break_attestations/{id}/answer`.
                    items:
                      "$ref": "#/components/schemas/RestBreakConfirmation"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so clocking in from a personal device is turned off.
            Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable — the
            employee must clock in at the kiosk.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Check-in validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/start_break":
    post:
      tags:
      - Attendance
      summary: Start a break
      description: Starts a break for the current attendance session, optionally setting
        a break type.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                break_type_id:
                  type: integer
                  description: Type of break (optional, uses default if not provided)
      responses:
        '200':
          description: Break started successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  managed_by:
                    type: string
                    nullable: true
                    example: UKG
                    description: The workforce management system of record whose punch
                      this is, or null. A managed punch (and its breaks) is read-only
                      here.
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  review_reason_label:
                    type: string
                    nullable: true
                    description: The words people see for `review_reason` ("Missing-punch
                      request" for `missing_punch_request`); null when there is no
                      reason.
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so breaks are started at the kiosk, not from a personal
            device. Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Break start validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/end_break":
    post:
      tags:
      - Attendance
      summary: End current break
      description: Ends the active break for the attendance record and updates totals.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                end_at_planned:
                  type: boolean
                  description: Record the break's end at its planned end, flagged
                    for review. Honoured only while the status answer offers `planned_break_end_at`;
                    otherwise the break ends now.
      responses:
        '200':
          description: Break ended successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  managed_by:
                    type: string
                    nullable: true
                    example: UKG
                    description: The workforce management system of record whose punch
                      this is, or null. A managed punch (and its breaks) is read-only
                      here.
                  id:
                    type: integer
                  user_id:
                    type: integer
                  shift_id:
                    type: integer
                  business_id:
                    type: integer
                  check_in_time:
                    type: string
                    format: date-time
                    nullable: true
                  check_out_time:
                    type: string
                    format: date-time
                    nullable: true
                  status:
                    type: string
                    enum:
                    - pending
                    - on_time
                    - late
                    - missed
                    - completed
                    - absence_reported
                    - excused
                    - no_show
                  location_verified:
                    type: boolean
                    default: false
                  photo_verification_required:
                    type: boolean
                    default: false
                  photo_verification_failed_reason:
                    type: string
                    nullable: true
                  is_unknown_device:
                    type: boolean
                    default: false
                  requires_review:
                    type: boolean
                    default: false
                  review_reason:
                    type: string
                    nullable: true
                  review_reason_label:
                    type: string
                    nullable: true
                    description: The words people see for `review_reason` ("Missing-punch
                      request" for `missing_punch_request`); null when there is no
                      reason.
                  reviewed_by_user_id:
                    type: integer
                    nullable: true
                  reviewed_at:
                    type: string
                    format: date-time
                    nullable: true
                  excused_by_user_id:
                    type: integer
                    nullable: true
                  excused_at:
                    type: string
                    format: date-time
                    nullable: true
                  absence_report_id:
                    type: integer
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
                  user:
                    "$ref": "#/components/schemas/User"
                  shift:
                    allOf:
                    - "$ref": "#/components/schemas/Shift"
                    nullable: true
                    description: 'The record''s shift. A SUBSET of the Shift schema:
                      id, name, start_time, end_time, formatted_date, formatted_time
                      — plus status, location and location_department when `include=shift_details`
                      is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                      ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                      are byte-identical to what `GET /shifts` returns for the same
                      shift.id, and do not vary with the caller''s profile timezone
                      — clients should prefer them over formatting `start_time` locally,
                      which has no usable zone once an SDK decodes it to a bare instant.
                      `formatted_date` always names the shift''s START day, so it
                      is correct for a shift crossing midnight.'
                  break_records:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BreakRecord"
        '403':
          description: |
            `error.code` = `kiosk_mode_enabled` — the business runs a shared Time
            Clock Kiosk, so breaks are ended at the kiosk, not from a personal
            device. Applies to the caller's own record only; carries
            `error.details.time_clock_kiosk_enabled: true`. Not retryable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: No active break to end
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/clock_in_brief":
    get:
      tags:
      - Attendance
      summary: Before you start — the clock-in brief
      description: |
        The one briefing at clock-in (Knowledge Layer E1) for the caller's OWN
        open punch, "since your last clock-out": what they still owe a
        confirmation on (the same set as `waiting_on_you` on `GET /home`), what
        changed on their schedule, the work waiting and what arrived meanwhile.
        The same brief the web and phone Time & Attendance pages show after a
        punch. Call it right after `POST /attendance_records/{id}/check_in`
        (whose response carries `clock_in_brief_path`), and again after a
        confirm or a relaunch. Nothing is stored: fetching it never changes the
        caller's Home "Catch me up". `waiting_on_you`, `to_decide` and `owed` are
        the Inbox's, so they are `null` for a token without `read:notifications`
        (the native app's sign-in tokens carry it).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The brief.
          content:
            application/json:
              schema:
                type: object
                properties:
                  clock_in_brief:
                    "$ref": "#/components/schemas/ClockInBrief"
        '403':
          description: "`error.code` = `not_your_record` — the punch belongs to someone
            else."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`error.code` = `not_clocked_in` — the punch is not open."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/{id}/clock_in_brief/confirm":
    post:
      tags:
      - Attendance
      summary: Confirm a must-read from the clock-in brief
      description: |
        Confirms one owed item from the brief in place, for the caller's own open
        punch. Only items with `confirm_in_chat: true` — feed must-read posts and
        broadcasts; every other kind is confirmed on its own page (its `url`).
        Needs `write:attendance` and `write:notifications` (an acknowledgment of
        something the Inbox says is owed); a broadcast also needs
        `write:broadcasts`. Answers with the brief as it now stands.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - kind
              - item_id
              properties:
                kind:
                  type: string
                  description: The item's `kind` (`must_read_post` or `broadcast`).
                  example: must_read_post
                item_id:
                  type: integer
                  description: The item's `id`.
      responses:
        '200':
          description: Confirmed (or already confirmed — `changed` is false then).
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Acknowledged.
                  changed:
                    type: boolean
                  clock_in_brief:
                    "$ref": "#/components/schemas/ClockInBrief"
        '403':
          description: |
            `error.code` = `not_your_record`, or `insufficient_permissions` when a
            broadcast is confirmed with a token lacking `write:broadcasts`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            `error.code` = `not_clocked_in`; `confirm_on_page` — this kind is
            confirmed on its own page; `not_pending` — the item isn't waiting on
            the caller; or the kind's own refusal (`closed`, `own_post`, …).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/attendance_records/status":
    get:
      tags:
      - Attendance
      summary: Get the caller's live clock status
      description: |
        Whether the authenticated user is on the clock right now, since when, where, and whether a
        break is running. Works with an employee's own-attendance token (`read:own_attendance`).
        Also returns the rest-break questions put off to this clock-in and the undo-clock-out window
        (the same `undo` object `POST /attendance_records/{id}/check_out` returns). Alias:
        `GET /attendance/status`.
      responses:
        '200':
          description: Live clock status
          content:
            application/json:
              schema:
                type: object
                properties:
                  clocked_in:
                    type: boolean
                  since:
                    type: string
                    format: date-time
                    description: Clock-in time (present only while clocked in)
                  work_mode:
                    type: string
                    description: on_site, remote or off_site (present only while clocked
                      in)
                  off_site_reason:
                    type: string
                    nullable: true
                  on_break:
                    type: boolean
                    description: Any running break, paid or unpaid (present only while
                      clocked in)
                  location:
                    type: object
                    nullable: true
                  attendance_record_id:
                    type: integer
                  rest_break_confirmations:
                    type: array
                    items:
                      type: object
                  punches_paused:
                    type: boolean
                    description: |
                      True while the tenant's maintenance refuses punches for this caller
                      (the answer `GET /home` gives as `attendance_policy.punches_paused`):
                      draw Clock In / Clock Out and breaks disabled. While true,
                      `shift_end_clock_out_at` and `planned_break_end_at` are null.
                  shift_end_clock_out_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      The shift's end, when clocking was paused then and has since resumed:
                      offer "Clock out at <this time>" beside Clock Out, and send
                      `close_at_shift_end: true` with `POST /attendance_records/{id}/check_out`
                      to record it (flagged for review). Null otherwise, and while punches are paused.
                  planned_break_end_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: |
                      The running break's planned end, when clocking was paused then and has
                      since resumed: offer "End break at <this time>" beside End Break, and send
                      `end_at_planned: true` with `POST /attendance_records/{id}/end_break` to
                      record it (flagged for review). Null otherwise, and while punches are paused.
                  undo:
                    type: object
                    description: The undo-clock-out window; `undoable` is false while
                      a punch is open
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/attendance_records/currently_clocked_in":
    get:
      tags:
      - Attendance
      summary: List who is on the clock right now
      description: |
        The live roster ("who is on site now") — the emergency-mustering feed. One row per person, their
        most recent open punch, longest on shift first. Scoped to the people the caller may see (an
        admin, everyone; a manager, their locations and reports). Requires `read:attendance`; an
        own-attendance token is refused. A coworker's email is included only where the People
        directory shows email to the caller. An unknown `work_mode` value is dropped (and named in
        `meta`) rather than returning an empty roster. Alias: `GET /attendance/currently_clocked_in`.
      parameters:
      - name: location_id
        in: query
        required: false
        schema:
          type: integer
        description: Only punches at this location or beneath it
      - name: work_mode
        in: query
        required: false
        schema:
          type: string
        description: Comma-separated work modes (on_site, remote, off_site)
      - name: page
        in: query
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          maximum: 100
          default: 25
      responses:
        '200':
          description: Live roster
          content:
            application/json:
              schema:
                type: object
                properties:
                  currently_clocked_in:
                    type: array
                    items:
                      type: object
                      properties:
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                            first_name:
                              type: string
                            last_name:
                              type: string
                            email:
                              type: string
                              nullable: true
                        since:
                          type: string
                          format: date-time
                        work_mode:
                          type: string
                        off_site_reason:
                          type: string
                          nullable: true
                        location:
                          type: object
                          nullable: true
                        attendance_record_id:
                          type: integer
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      page:
                        type: integer
                      per_page:
                        type: integer
                      counts_by_work_mode:
                        type: object
                        additionalProperties:
                          type: integer
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/attendance_records/dashboard":
    get:
      tags:
      - Attendance
      summary: Get attendance dashboard statistics
      description: Provides summarized counts and KPIs for the attendance dashboard.
      responses:
        '200':
          description: Dashboard statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_records:
                    type: integer
                  pending_review:
                    type: integer
                  missing_check_out:
                    type: integer
                  today_records:
                    type: integer
  "/attendance_records/analytics":
    get:
      tags:
      - Attendance
      summary: Get attendance analytics
      description: Returns aggregate analytics for attendance across dimensions such
        as status and location.
      responses:
        '200':
          description: Attendance analytics data
          content:
            application/json:
              schema:
                type: object
                properties:
                  by_status:
                    type: object
                    additionalProperties:
                      type: integer
                  by_department:
                    type: object
                    additionalProperties:
                      type: integer
                  by_location:
                    type: object
                    additionalProperties:
                      type: integer
                  average_duration:
                    type: number
                    format: float
  "/attendance_records/history":
    get:
      tags:
      - Attendance
      summary: Get attendance history
      description: Returns a recent history list of attendance records with a configurable
        limit.
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          default: 100
      responses:
        '200':
          description: Attendance history
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    managed_by:
                      type: string
                      nullable: true
                      example: UKG
                      description: The workforce management system of record whose
                        punch this is, or null. A managed punch (and its breaks) is
                        read-only here.
                    id:
                      type: integer
                    user_id:
                      type: integer
                    shift_id:
                      type: integer
                    business_id:
                      type: integer
                    check_in_time:
                      type: string
                      format: date-time
                      nullable: true
                    check_out_time:
                      type: string
                      format: date-time
                      nullable: true
                    status:
                      type: string
                      enum:
                      - pending
                      - on_time
                      - late
                      - missed
                      - completed
                      - absence_reported
                      - excused
                      - no_show
                    location_verified:
                      type: boolean
                      default: false
                    photo_verification_required:
                      type: boolean
                      default: false
                    photo_verification_failed_reason:
                      type: string
                      nullable: true
                    is_unknown_device:
                      type: boolean
                      default: false
                    requires_review:
                      type: boolean
                      default: false
                    review_reason:
                      type: string
                      nullable: true
                    review_reason_label:
                      type: string
                      nullable: true
                      description: The words people see for `review_reason` ("Missing-punch
                        request" for `missing_punch_request`); null when there is
                        no reason.
                    reviewed_by_user_id:
                      type: integer
                      nullable: true
                    reviewed_at:
                      type: string
                      format: date-time
                      nullable: true
                    excused_by_user_id:
                      type: integer
                      nullable: true
                    excused_at:
                      type: string
                      format: date-time
                      nullable: true
                    absence_report_id:
                      type: integer
                      nullable: true
                    notes:
                      type: string
                      nullable: true
                    created_at:
                      type: string
                      format: date-time
                    updated_at:
                      type: string
                      format: date-time
                    user:
                      "$ref": "#/components/schemas/User"
                    shift:
                      allOf:
                      - "$ref": "#/components/schemas/Shift"
                      nullable: true
                      description: 'The record''s shift. A SUBSET of the Shift schema:
                        id, name, start_time, end_time, formatted_date, formatted_time
                        — plus status, location and location_department when `include=shift_details`
                        is requested. `formatted_date` ("Wednesday, Aug 05") and `formatted_time`
                        ("01:30 PM - 09:30 PM") are rendered in the SHIFT''s own timezone,
                        are byte-identical to what `GET /shifts` returns for the same
                        shift.id, and do not vary with the caller''s profile timezone
                        — clients should prefer them over formatting `start_time`
                        locally, which has no usable zone once an SDK decodes it to
                        a bare instant. `formatted_date` always names the shift''s
                        START day, so it is correct for a shift crossing midnight.'
                    break_records:
                      type: array
                      items:
                        "$ref": "#/components/schemas/BreakRecord"
  "/attendance_records/missing_check_out":
    get:
      tags:
      - Attendance
      summary: Get records missing check-out
      description: |
        Lists attendance records still open (no check-out) more than 24 hours after clock-in. An employee sees only their own; an administrator or manager sees the records they may read.

        Returns at most 100 rows, newest clock-in first. The cap is disclosed, not
        silent: `total_count` is the true number of matching records and
        `truncated` is true when more exist than were returned. A client showing
        this list should say so (e.g. "Showing 100 of N") rather than present the
        first 100 as the whole set.
      parameters:
      - name: user_id
        in: query
        required: false
        description: |
          Only this person's records, within what the caller may read (it narrows, never widens;
          an id outside the caller's scope returns none). Applied before the count and the cap, so
          `total_count` is that person's own total. A first-person view (the caller's own punches)
          passes the caller's id: an administrator's unfiltered list is the whole tenant, and the
          caller's own rows could fall past its first 100.
        schema:
          type: integer
      responses:
        '200':
          description: Records missing check-out
          content:
            application/json:
              schema:
                type: object
                required:
                - missing_check_out
                - total_count
                - returned_count
                - truncated
                properties:
                  missing_check_out:
                    type: array
                    items:
                      type: object
                      properties:
                        managed_by:
                          type: string
                          nullable: true
                          example: UKG
                          description: The workforce management system of record whose
                            punch this is, or null. A managed punch (and its breaks)
                            is read-only here.
                        id:
                          type: integer
                        user_id:
                          type: integer
                        shift_id:
                          type: integer
                        business_id:
                          type: integer
                        check_in_time:
                          type: string
                          format: date-time
                          nullable: true
                        check_out_time:
                          type: string
                          format: date-time
                          nullable: true
                        status:
                          type: string
                          enum:
                          - pending
                          - on_time
                          - late
                          - missed
                          - completed
                          - absence_reported
                          - excused
                          - no_show
                        location_verified:
                          type: boolean
                          default: false
                        photo_verification_required:
                          type: boolean
                          default: false
                        photo_verification_failed_reason:
                          type: string
                          nullable: true
                        is_unknown_device:
                          type: boolean
                          default: false
                        requires_review:
                          type: boolean
                          default: false
                        review_reason:
                          type: string
                          nullable: true
                        review_reason_label:
                          type: string
                          nullable: true
                          description: The words people see for `review_reason` ("Missing-punch
                            request" for `missing_punch_request`); null when there
                            is no reason.
                        reviewed_by_user_id:
                          type: integer
                          nullable: true
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                        excused_by_user_id:
                          type: integer
                          nullable: true
                        excused_at:
                          type: string
                          format: date-time
                          nullable: true
                        absence_report_id:
                          type: integer
                          nullable: true
                        notes:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        user:
                          "$ref": "#/components/schemas/User"
                        shift:
                          allOf:
                          - "$ref": "#/components/schemas/Shift"
                          nullable: true
                          description: 'The record''s shift. A SUBSET of the Shift
                            schema: id, name, start_time, end_time, formatted_date,
                            formatted_time — plus status, location and location_department
                            when `include=shift_details` is requested. `formatted_date`
                            ("Wednesday, Aug 05") and `formatted_time` ("01:30 PM
                            - 09:30 PM") are rendered in the SHIFT''s own timezone,
                            are byte-identical to what `GET /shifts` returns for the
                            same shift.id, and do not vary with the caller''s profile
                            timezone — clients should prefer them over formatting
                            `start_time` locally, which has no usable zone once an
                            SDK decodes it to a bare instant. `formatted_date` always
                            names the shift''s START day, so it is correct for a shift
                            crossing midnight.'
                        break_records:
                          type: array
                          items:
                            "$ref": "#/components/schemas/BreakRecord"
                  total_count:
                    type: integer
                    description: Every matching record, not only the rows returned.
                  returned_count:
                    type: integer
                    description: Rows in this response (at most 100).
                  truncated:
                    type: boolean
                    description: True when total_count exceeds the 100 rows returned.
  "/attendance_records/requires_review":
    get:
      tags:
      - Attendance
      summary: Get records requiring review
      description: |
        Lists attendance records flagged for review by automated or manual checks — the review queue the caller may decide.

        Returns at most 100 rows, newest clock-in first. The cap is disclosed, not
        silent: `total_count` is the true number of matching records and
        `truncated` is true when more exist than were returned. A client showing
        this list should say so (e.g. "Showing 100 of N") rather than present the
        first 100 as the whole set.
      parameters:
      - name: user_id
        in: query
        required: false
        description: |
          Only this person's records, within what the caller may read (it narrows, never widens;
          an id outside the caller's scope returns none). Applied before the count and the cap, so
          `total_count` is that person's own total. A first-person view (the caller's own punches)
          passes the caller's id: an administrator's unfiltered list is the whole tenant, and the
          caller's own rows could fall past its first 100.
        schema:
          type: integer
      responses:
        '200':
          description: Records requiring review
          content:
            application/json:
              schema:
                type: object
                required:
                - requires_review
                - total_count
                - returned_count
                - truncated
                properties:
                  requires_review:
                    type: array
                    items:
                      type: object
                      properties:
                        managed_by:
                          type: string
                          nullable: true
                          example: UKG
                          description: The workforce management system of record whose
                            punch this is, or null. A managed punch (and its breaks)
                            is read-only here.
                        id:
                          type: integer
                        user_id:
                          type: integer
                        shift_id:
                          type: integer
                        business_id:
                          type: integer
                        check_in_time:
                          type: string
                          format: date-time
                          nullable: true
                        check_out_time:
                          type: string
                          format: date-time
                          nullable: true
                        status:
                          type: string
                          enum:
                          - pending
                          - on_time
                          - late
                          - missed
                          - completed
                          - absence_reported
                          - excused
                          - no_show
                        location_verified:
                          type: boolean
                          default: false
                        photo_verification_required:
                          type: boolean
                          default: false
                        photo_verification_failed_reason:
                          type: string
                          nullable: true
                        is_unknown_device:
                          type: boolean
                          default: false
                        requires_review:
                          type: boolean
                          default: false
                        review_reason:
                          type: string
                          nullable: true
                        review_reason_label:
                          type: string
                          nullable: true
                          description: The words people see for `review_reason` ("Missing-punch
                            request" for `missing_punch_request`); null when there
                            is no reason.
                        reviewed_by_user_id:
                          type: integer
                          nullable: true
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                        excused_by_user_id:
                          type: integer
                          nullable: true
                        excused_at:
                          type: string
                          format: date-time
                          nullable: true
                        absence_report_id:
                          type: integer
                          nullable: true
                        notes:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        user:
                          "$ref": "#/components/schemas/User"
                        shift:
                          allOf:
                          - "$ref": "#/components/schemas/Shift"
                          nullable: true
                          description: 'The record''s shift. A SUBSET of the Shift
                            schema: id, name, start_time, end_time, formatted_date,
                            formatted_time — plus status, location and location_department
                            when `include=shift_details` is requested. `formatted_date`
                            ("Wednesday, Aug 05") and `formatted_time` ("01:30 PM
                            - 09:30 PM") are rendered in the SHIFT''s own timezone,
                            are byte-identical to what `GET /shifts` returns for the
                            same shift.id, and do not vary with the caller''s profile
                            timezone — clients should prefer them over formatting
                            `start_time` locally, which has no usable zone once an
                            SDK decodes it to a bare instant. `formatted_date` always
                            names the shift''s START day, so it is correct for a shift
                            crossing midnight.'
                        break_records:
                          type: array
                          items:
                            "$ref": "#/components/schemas/BreakRecord"
                  total_count:
                    type: integer
                    description: Every matching record, not only the rows returned.
                  returned_count:
                    type: integer
                    description: Rows in this response (at most 100).
                  truncated:
                    type: boolean
                    description: True when total_count exceeds the 100 rows returned.
  "/attendance_records/auto_check_out":
    post:
      tags:
      - Attendance
      summary: Auto check-out stale records
      description: Automatically check out records that have been open for more than
        24 hours
      responses:
        '200':
          description: Auto check-out completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  results:
                    type: object
                    properties:
                      success:
                        type: array
                        items:
                          type: integer
                      failed:
                        type: array
                        items:
                          type: object
  "/absence_reasons":
    get:
      tags:
      - Attendance
      summary: The tenant's absence reason codes
      description: |
        The configured reason list behind the "Report Delay or Absence" form —
        the same set, in the same order, that the web picker is built from. Read
        this instead of shipping a built-in list: the six defaults are only a
        SEED, and a tenant may rename, retire or add codes at any time from
        /admin/absence_reason_codes.

        Not paginated — the whole set is one select's worth of options.

        Read-only. Reason codes are admin configuration; there is no API to
        create or edit one.
      security:
      - BearerAuth: []
      parameters:
      - name: include_inactive
        in: query
        schema:
          type: boolean
          default: false
        description: |
          Include RETIRED codes. Default false, which is what a
          new-report picker wants — nothing new may be filed under a retired
          reason.

          Pass true only to NAME a reason on a report that already exists: a
          code is retired with a flag rather than deleted, so a report filed
          before the retirement still points at one. Every row carries
          `active`, so a client asking for the full set can still keep retired
          codes out of the picker.
      responses:
        '200':
          description: Reason codes for the caller's business
          content:
            application/json:
              schema:
                type: object
                properties:
                  absence_reasons:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AbsenceReason"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      include_inactive:
                        type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: 'Time & Attendance is not enabled for the business or is not
            accessible to this user, or the token lacks `read:attendance` / `read:own_attendance`.

            '
  "/shift_marketplace/summary":
    get:
      tags:
      - Shift Marketplace
      summary: Menu counts
      description: |
        The numbers the Shift Marketplace menu shows beside its items, for a client
        that draws its own menu. They are the same counts the desktop menu and the
        phone web page show for the same person (one counter serves all three),
        counted over the whole set, not one page.

        - `pickable_now` — the Listings item and the phone's Browse button: shifts
          the caller can pick up or trade for right now. Leaves out the caller's own
          listings, shifts they already asked for or applied to trade for, and
          pickups the claim rules (rest period, hours, leave) would refuse.
        - `claims_waiting_on_you` — the Shift Claims item: pending shift claims the
          caller may approve or reject. Always 0 when the menu offers the caller no
          Shift Claims item.
        - `can_decide_claims` — whether the menu offers the caller a Shift Claims
          item at all (they are named by the tenant's claim approval authority, or
          they administer the app and may manage shifts) AND the token carries
          `write:shifts`, which approve and reject require.

        A menu draws no number at zero; this endpoint answers 0.

        **Scope**: `read:shifts`.
        **Business Scoping**: Counts are scoped to the current business.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The menu's counts for the caller
          content:
            application/json:
              schema:
                type: object
                required:
                - pickable_now
                - claims_waiting_on_you
                - can_decide_claims
                properties:
                  pickable_now:
                    type: integer
                    minimum: 0
                    description: Shifts the caller can pick up or trade for right
                      now.
                    example: 3
                  claims_waiting_on_you:
                    type: integer
                    minimum: 0
                    description: Pending shift claims waiting on the caller's approval
                      (0 when the caller has no Shift Claims item).
                    example: 2
                  can_decide_claims:
                    type: boolean
                    description: Whether the caller has a Shift Claims queue to decide
                      from and the token may decide (write:shifts).
                    example: true
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Shift Marketplace is off for the business, the caller cannot
            see it, or the token lacks read:shifts.
  "/shift_marketplace/listings":
    get:
      tags:
      - Shift Marketplace
      summary: List Shift Marketplace Listings
      description: |
        Returns a list of shift marketplace listings with filtering by listing type, status, price, and location.
        Supports pickup-only, trade-only, and both listing types.
      security:
      - BearerAuth: []
      parameters:
      - name: listing_type
        in: query
        description: Filter by listing type
        schema:
          type: string
          enum:
          - pickup
          - trade_only
          - both
      - name: status
        in: query
        description: Filter by listing status
        schema:
          type: string
          enum:
          - open
          - filled
          - closed
          - cancelled
      - name: min_price
        in: query
        schema:
          type: number
      - name: max_price
        in: query
        schema:
          type: number
      - name: location_id
        in: query
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: List of marketplace listings
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: A marketplace listing for shifts (pickup, trade_only,
                          or both)
                        properties:
                          id:
                            type: integer
                            example: 1
                          listing_type:
                            type: string
                            enum:
                            - pickup
                            - trade_only
                            - both
                            example: pickup
                            description: Type of listing - pickup (direct claim),
                              trade_only (requires application), or both
                          status:
                            type: string
                            enum:
                            - open
                            - filled
                            - closed
                            - cancelled
                            example: open
                          price:
                            type: number
                            nullable: true
                            example: 0.01
                          currency:
                            type: string
                            example: USD
                          notes:
                            type: string
                            nullable: true
                            example: Need someone to cover this shift
                          urgent:
                            type: boolean
                            description: Whether this listing is marked as urgent
                            example: false
                          created_at:
                            type: string
                            format: date-time
                          updated_at:
                            type: string
                            format: date-time
                          claimed_at:
                            type: string
                            format: date-time
                            nullable: true
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              date:
                                type: string
                                format: date
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          claimed_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          can_claim:
                            type: boolean
                            description: Whether current user can claim this listing
                              (false while their own request on it waits)
                          pending_request:
                            type: object
                            nullable: true
                            description: The current user's own pickup request on
                              this listing's shift that is still waiting for approval;
                              withdraw it with POST /api/v1/shift_marketplace/listings/{id}/withdraw_claim
                            properties:
                              id:
                                type: integer
                              requested_at:
                                type: string
                                format: date-time
                                nullable: true
                          accepts_applications:
                            type: boolean
                            description: Whether this listing accepts applications
                              (trade_only or both)
                          is_trade_only:
                            type: boolean
                            description: Whether this is a trade-only listing
                          has_applied:
                            type: boolean
                            description: Whether current user has applied to this
                              listing
                          application_status:
                            type: string
                            nullable: true
                            enum:
                            - pending
                            - accepted
                            - rejected
                            - withdrawn
                            description: Current user's application status (if applied)
                          application_id:
                            type: integer
                            nullable: true
                            description: Current user's application ID (if applied)
                          is_owner:
                            type: boolean
                            description: Whether current user owns this listing
                          lister_status:
                            type: string
                            enum:
                            - open
                            - filled
                            - traded
                            - closed
                            - cancelled
                            description: Owner rows only (is_owner true). The status
                              as its lister reads it — status, except a listing filled
                              by an accepted trade is traded (filled = picked up).
                              Switch on this key, never on lister_status_label.
                            example: traded
                          lister_status_label:
                            type: string
                            description: Owner rows only (is_owner true). The word
                              the web and phone pages show the lister (Available,
                              Covered, Traded, Closed, Cancelled), in the language
                              the client asks for (the locale param, else Accept-Language,
                              else the person's own language).
                            example: Traded
                          can_edit:
                            type: boolean
                          can_delete:
                            type: boolean
                          applications:
                            type: array
                            description: Owner-only application summaries for this
                              listing
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                status:
                                  type: string
                                  enum:
                                  - pending
                                  - accepted
                                  - rejected
                                  - withdrawn
                                notes:
                                  type: string
                                  nullable: true
                                created_at:
                                  type: string
                                  format: date-time
                                offered_shift:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                                    start_time:
                                      type: string
                                      format: date-time
                                    end_time:
                                      type: string
                                      format: date-time
                                    formatted_date:
                                      type: string
                                    formatted_time:
                                      type: string
                                    location:
                                      type: object
                                      nullable: true
                                      properties:
                                        id:
                                          type: integer
                                        name:
                                          type: string
                                applicant:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                                    avatar_url:
                                      type: string
                                      nullable: true
                                    avatar_thumbnail_url:
                                      type: string
                                      nullable: true
                          applications_count:
                            type: integer
                            description: Total applications (owner only)
                          pending_applications_count:
                            type: integer
                            description: Pending applications count (owner only)
                    meta:
                      description: 'The four pagination values plus Kaminari''s page
                        numbers (this namespace''s own pagination meta: no `has_next_page`
                        / `has_prev_page` flags).'
                      allOf:
                      - type: object
                        description: Pagination metadata
                        properties:
                          total_count:
                            type: integer
                            description: Total number of items
                            example: 150
                          total_pages:
                            type: integer
                            description: Total number of pages
                            example: 6
                          current_page:
                            type: integer
                            description: Current page number
                            example: 1
                          per_page:
                            type: integer
                            description: Items per page
                            example: 25
                        required:
                        - total_count
                        - total_pages
                        - current_page
                        - per_page
                      - type: object
                        properties:
                          next_page:
                            type: integer
                            nullable: true
                            description: The next page number; null on the last page.
                          prev_page:
                            type: integer
                            nullable: true
                            description: The previous page number; null on the first
                              page.
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shift Marketplace
      summary: Create Marketplace Listing
      description: Create a new shift marketplace listing (pickup, trade_only, or
        both)
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - listing
              properties:
                listing:
                  type: object
                  required:
                  - shift_id
                  - listing_type
                  properties:
                    shift_id:
                      type: integer
                      example: 123
                    listing_type:
                      type: string
                      enum:
                      - pickup
                      - trade_only
                      - both
                      example: pickup
                      description: Type of listing - pickup (direct claim), trade_only
                        (requires application), or both
                    price:
                      type: number
                      example: 0.01
                      description: Optional price/incentive for pickup
                    currency:
                      type: string
                      example: USD
                      default: USD
                    notes:
                      type: string
                      example: Need someone to cover this shift
                      description: Optional notes about the listing
      responses:
        '200':
          description: Listing created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing (false
                          while their own request on it waits)
                      pending_request:
                        type: object
                        nullable: true
                        description: The current user's own pickup request on this
                          listing's shift that is still waiting for approval; withdraw
                          it with POST /api/v1/shift_marketplace/listings/{id}/withdraw_claim
                        properties:
                          id:
                            type: integer
                          requested_at:
                            type: string
                            format: date-time
                            nullable: true
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      lister_status:
                        type: string
                        enum:
                        - open
                        - filled
                        - traded
                        - closed
                        - cancelled
                        description: Owner rows only (is_owner true). The status as
                          its lister reads it — status, except a listing filled by
                          an accepted trade is traded (filled = picked up). Switch
                          on this key, never on lister_status_label.
                        example: traded
                      lister_status_label:
                        type: string
                        description: Owner rows only (is_owner true). The word the
                          web and phone pages show the lister (Available, Covered,
                          Traded, Closed, Cancelled), in the language the client asks
                          for (the locale param, else Accept-Language, else the person's
                          own language).
                        example: Traded
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shift_marketplace/listings/{id}":
    get:
      tags:
      - Shift Marketplace
      summary: Get Listing Details
      description: |
        Returns detailed information about a specific marketplace listing including:
        - Basic listing information (type, status, price)
        - Associated shift details
        - Current user's application status (has_applied, application_status, application_id)
        - Applications list if user is the listing owner
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Listing details
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing (false
                          while their own request on it waits)
                      pending_request:
                        type: object
                        nullable: true
                        description: The current user's own pickup request on this
                          listing's shift that is still waiting for approval; withdraw
                          it with POST /api/v1/shift_marketplace/listings/{id}/withdraw_claim
                        properties:
                          id:
                            type: integer
                          requested_at:
                            type: string
                            format: date-time
                            nullable: true
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      lister_status:
                        type: string
                        enum:
                        - open
                        - filled
                        - traded
                        - closed
                        - cancelled
                        description: Owner rows only (is_owner true). The status as
                          its lister reads it — status, except a listing filled by
                          an accepted trade is traded (filled = picked up). Switch
                          on this key, never on lister_status_label.
                        example: traded
                      lister_status_label:
                        type: string
                        description: Owner rows only (is_owner true). The word the
                          web and phone pages show the lister (Available, Covered,
                          Traded, Closed, Cancelled), in the language the client asks
                          for (the locale param, else Accept-Language, else the person's
                          own language).
                        example: Traded
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Shift Marketplace
      summary: Update Listing
      description: Update a marketplace listing (owner only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                listing:
                  type: object
                  properties:
                    listing_type:
                      type: string
                      enum:
                      - pickup
                      - trade_only
                      - both
                    price:
                      type: number
                    notes:
                      type: string
      responses:
        '200':
          description: Listing updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing (false
                          while their own request on it waits)
                      pending_request:
                        type: object
                        nullable: true
                        description: The current user's own pickup request on this
                          listing's shift that is still waiting for approval; withdraw
                          it with POST /api/v1/shift_marketplace/listings/{id}/withdraw_claim
                        properties:
                          id:
                            type: integer
                          requested_at:
                            type: string
                            format: date-time
                            nullable: true
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      lister_status:
                        type: string
                        enum:
                        - open
                        - filled
                        - traded
                        - closed
                        - cancelled
                        description: Owner rows only (is_owner true). The status as
                          its lister reads it — status, except a listing filled by
                          an accepted trade is traded (filled = picked up). Switch
                          on this key, never on lister_status_label.
                        example: traded
                      lister_status_label:
                        type: string
                        description: Owner rows only (is_owner true). The word the
                          web and phone pages show the lister (Available, Covered,
                          Traded, Closed, Cancelled), in the language the client asks
                          for (the locale param, else Accept-Language, else the person's
                          own language).
                        example: Traded
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Shift Marketplace
      summary: Delete Listing
      description: |
        Delete a marketplace listing (owner only)

        Delete-level (owner decision 2026-10-04): requires the `destructive:shift_marketplace`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Listing deleted
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shift_marketplace/listings/{id}/claim":
    post:
      tags:
      - Shift Marketplace
      summary: Claim Listing
      description: |
        Claim a pickup or both listing. Trade-only listings cannot be claimed directly.
        Use the applications endpoint instead for trade-only listings.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Listing claimed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  listing:
                    type: object
                    description: A marketplace listing for shifts (pickup, trade_only,
                      or both)
                    properties:
                      id:
                        type: integer
                        example: 1
                      listing_type:
                        type: string
                        enum:
                        - pickup
                        - trade_only
                        - both
                        example: pickup
                        description: Type of listing - pickup (direct claim), trade_only
                          (requires application), or both
                      status:
                        type: string
                        enum:
                        - open
                        - filled
                        - closed
                        - cancelled
                        example: open
                      price:
                        type: number
                        nullable: true
                        example: 0.01
                      currency:
                        type: string
                        example: USD
                      notes:
                        type: string
                        nullable: true
                        example: Need someone to cover this shift
                      urgent:
                        type: boolean
                        description: Whether this listing is marked as urgent
                        example: false
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      claimed_at:
                        type: string
                        format: date-time
                        nullable: true
                      shift:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          date:
                            type: string
                            format: date
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                      created_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      claimed_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      can_claim:
                        type: boolean
                        description: Whether current user can claim this listing (false
                          while their own request on it waits)
                      pending_request:
                        type: object
                        nullable: true
                        description: The current user's own pickup request on this
                          listing's shift that is still waiting for approval; withdraw
                          it with POST /api/v1/shift_marketplace/listings/{id}/withdraw_claim
                        properties:
                          id:
                            type: integer
                          requested_at:
                            type: string
                            format: date-time
                            nullable: true
                      accepts_applications:
                        type: boolean
                        description: Whether this listing accepts applications (trade_only
                          or both)
                      is_trade_only:
                        type: boolean
                        description: Whether this is a trade-only listing
                      has_applied:
                        type: boolean
                        description: Whether current user has applied to this listing
                      application_status:
                        type: string
                        nullable: true
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        description: Current user's application status (if applied)
                      application_id:
                        type: integer
                        nullable: true
                        description: Current user's application ID (if applied)
                      is_owner:
                        type: boolean
                        description: Whether current user owns this listing
                      lister_status:
                        type: string
                        enum:
                        - open
                        - filled
                        - traded
                        - closed
                        - cancelled
                        description: Owner rows only (is_owner true). The status as
                          its lister reads it — status, except a listing filled by
                          an accepted trade is traded (filled = picked up). Switch
                          on this key, never on lister_status_label.
                        example: traded
                      lister_status_label:
                        type: string
                        description: Owner rows only (is_owner true). The word the
                          web and phone pages show the lister (Available, Covered,
                          Traded, Closed, Cancelled), in the language the client asks
                          for (the locale param, else Accept-Language, else the person's
                          own language).
                        example: Traded
                      can_edit:
                        type: boolean
                      can_delete:
                        type: boolean
                      applications:
                        type: array
                        description: Owner-only application summaries for this listing
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            status:
                              type: string
                              enum:
                              - pending
                              - accepted
                              - rejected
                              - withdrawn
                            notes:
                              type: string
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            offered_shift:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                start_time:
                                  type: string
                                  format: date-time
                                end_time:
                                  type: string
                                  format: date-time
                                formatted_date:
                                  type: string
                                formatted_time:
                                  type: string
                                location:
                                  type: object
                                  nullable: true
                                  properties:
                                    id:
                                      type: integer
                                    name:
                                      type: string
                            applicant:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                avatar_url:
                                  type: string
                                  nullable: true
                                avatar_thumbnail_url:
                                  type: string
                                  nullable: true
                      applications_count:
                        type: integer
                        description: Total applications (owner only)
                      pending_applications_count:
                        type: integer
                        description: Pending applications count (owner only)
        '422':
          description: Cannot claim listing (trade-only, conflicts, or requirements
            not met)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/listings/{id}/withdraw_claim":
    post:
      tags:
      - Shift Marketplace
      summary: Withdraw My Pickup Request
      description: |
        Withdraw the caller's own pending pickup request for this listing's shift.
        A listing's `pending_request` names the request; `can_claim` is false while one waits.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Request withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  claim_request:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        example: withdrawn
                      message:
                        type: string
        '404':
          description: No pending request of the caller's for this shift
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: The request could not be withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications":
    get:
      tags:
      - Shift Marketplace
      summary: List User's Applications
      description: Returns applications submitted by the current user for trade-only
        or both listings
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Filter by application status
        schema:
          type: string
          enum:
          - pending
          - accepted
          - rejected
          - withdrawn
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: List of applications
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: An application to a trade listing
                        properties:
                          id:
                            type: integer
                            example: 1
                          status:
                            type: string
                            enum:
                            - pending
                            - accepted
                            - rejected
                            - withdrawn
                            example: pending
                          status_label:
                            type: string
                            description: The status as the app shows it. An application
                              the app closed (its listing filled, closed or cancelled,
                              or the offered shift gone) has status rejected and reads
                              "No Longer Available".
                            example: Pending
                          notes:
                            type: string
                            nullable: true
                            example: I can work this shift
                          created_at:
                            type: string
                            format: date-time
                          updated_at:
                            type: string
                            format: date-time
                          listing:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              listing_type:
                                type: string
                              status:
                                type: string
                              shift:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                                  start_time:
                                    type: string
                                    format: date-time
                                  end_time:
                                    type: string
                                    format: date-time
                                  formatted_date:
                                    type: string
                                  formatted_time:
                                    type: string
                                  location:
                                    type: object
                                    nullable: true
                                    properties:
                                      id:
                                        type: integer
                                      name:
                                        type: string
                              created_by:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                                  email:
                                    type: string
                                  avatar_url:
                                    type: string
                                    nullable: true
                                    description: Full-size profile photo URL (200x200)
                                      or fallback to initials-based avatar
                                  avatar_thumbnail_url:
                                    type: string
                                    nullable: true
                                    description: Thumbnail profile photo URL (40x40)
                                      optimized for list views
                          applicant:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          is_applicant:
                            type: boolean
                            description: Whether current user is the applicant
                          is_listing_owner:
                            type: boolean
                            description: Whether current user owns the listing
                          can_accept:
                            type: boolean
                            description: Whether current user can accept this application
                          can_reject:
                            type: boolean
                            description: Whether current user can reject this application
                          can_withdraw:
                            type: boolean
                            description: Whether current user can withdraw this application
                    meta:
                      description: 'The four pagination values plus Kaminari''s page
                        numbers (this namespace''s own pagination meta: no `has_next_page`
                        / `has_prev_page` flags).'
                      allOf:
                      - type: object
                        description: Pagination metadata
                        properties:
                          total_count:
                            type: integer
                            description: Total number of items
                            example: 150
                          total_pages:
                            type: integer
                            description: Total number of pages
                            example: 6
                          current_page:
                            type: integer
                            description: Current page number
                            example: 1
                          per_page:
                            type: integer
                            description: Items per page
                            example: 25
                        required:
                        - total_count
                        - total_pages
                        - current_page
                        - per_page
                      - type: object
                        properties:
                          next_page:
                            type: integer
                            nullable: true
                            description: The next page number; null on the last page.
                          prev_page:
                            type: integer
                            nullable: true
                            description: The previous page number; null on the first
                              page.
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shift Marketplace
      summary: Apply to Listing
      description: Offer one of your own upcoming shifts in exchange for a Trade Only
        or Pickup or Trade listing. An application must offer a shift; taking the
        shift without a trade is a pickup (claim the listing), which goes through
        the company's approval rule.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - application
              properties:
                application:
                  type: object
                  required:
                  - listing_id
                  - offered_shift_id
                  properties:
                    listing_id:
                      type: integer
                      example: 123
                    offered_shift_id:
                      type: integer
                      description: One of your own upcoming shifts, given in exchange.
                      example: 456
                    notes:
                      type: string
                      example: I can take your Thursday morning if you take my Sunday
                        afternoon.
      responses:
        '200':
          description: Application submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      status_label:
                        type: string
                        description: The status as the app shows it. An application
                          the app closed (its listing filled, closed or cancelled,
                          or the offered shift gone) has status rejected and reads
                          "No Longer Available".
                        example: Pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '422':
          description: Cannot apply (already applied, listing closed, wrong type,
            or no shift offered in exchange)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications/{id}":
    get:
      tags:
      - Shift Marketplace
      summary: Get Application Details
      description: View details of a specific application (applicant or listing owner
        only)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application details
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      status_label:
                        type: string
                        description: The status as the app shows it. An application
                          the app closed (its listing filled, closed or cancelled,
                          or the offered shift gone) has status rejected and reads
                          "No Longer Available".
                        example: Pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shift_marketplace/applications/{id}/accept":
    post:
      tags:
      - Shift Marketplace
      summary: Accept Application
      description: |
        Accept a pending application (listing owner only).
        This transfers the shift to the applicant and closes the listing.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application accepted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      status_label:
                        type: string
                        description: The status as the app shows it. An application
                          the app closed (its listing filled, closed or cancelled,
                          or the offered shift gone) has status rejected and reads
                          "No Longer Available".
                        example: Pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot accept (not pending or applicant has conflict)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications/{id}/reject":
    post:
      tags:
      - Shift Marketplace
      summary: Reject Application
      description: |
        Reject a pending application (listing owner only)

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:shift_marketplace` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      status_label:
                        type: string
                        description: The status as the app shows it. An application
                          the app closed (its listing filled, closed or cancelled,
                          or the offered shift gone) has status rejected and reads
                          "No Longer Available".
                        example: Pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot reject (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/applications/{id}/withdraw":
    post:
      tags:
      - Shift Marketplace
      summary: Withdraw Application
      description: |
        Withdraw a pending application (applicant only)

        Delete-level (owner decision 2026-10-04): requires the `destructive:shift_marketplace`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Application withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  application:
                    type: object
                    description: An application to a trade listing
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - rejected
                        - withdrawn
                        example: pending
                      status_label:
                        type: string
                        description: The status as the app shows it. An application
                          the app closed (its listing filled, closed or cancelled,
                          or the offered shift gone) has status rejected and reads
                          "No Longer Available".
                        example: Pending
                      notes:
                        type: string
                        nullable: true
                        example: I can work this shift
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      listing:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          listing_type:
                            type: string
                          status:
                            type: string
                          shift:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: object
                                nullable: true
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                          created_by:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                      applicant:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_applicant:
                        type: boolean
                        description: Whether current user is the applicant
                      is_listing_owner:
                        type: boolean
                        description: Whether current user owns the listing
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this application
                      can_reject:
                        type: boolean
                        description: Whether current user can reject this application
                      can_withdraw:
                        type: boolean
                        description: Whether current user can withdraw this application
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot withdraw (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/claim_requests":
    get:
      tags:
      - Shift Marketplace
      summary: List the pickups waiting on you
      description: |
        The pickup requests (shift claims) waiting on the caller to approve or
        decline — the Shift Marketplace Dashboard's "Waiting on you" and the Shift
        Claims queue's rows. They are the same claims the web lists for the same
        person, oldest first, and `meta.total_count` is the number the Shift Claims
        menu item and `GET /shift_marketplace/summary`'s `claims_waiting_on_you`
        show.

        A claim is either on a coworker's listed shift (`kind: listing` — approving
        moves the shift from `listed_by` to `user`) or on a management-posted open
        shift (`kind: open_shift`).

        `can_approve` is false while approving would be refused — the shift has
        started or been taken, its schedule is back in draft, the employee no
        longer qualifies, or a staffing rule (hours, rest, certifications) would
        break — and `cannot_approve_reason` says which. Both verdicts need a token
        holding `write:shifts`; a decline also needs `destructive:scheduling`
        (declining is a delete-level decision on a token, as on
        `POST /home/waiting_on_you/decide`).

        **Scope**: `read:shifts`.
        **Business Scoping**: Claims are scoped to the current business.
      parameters:
      - name: waiting_on
        in: query
        description: Whose decision the claims wait on. `me` is the only queue (and
          the default).
        schema:
          type: string
          enum:
          - me
          default: me
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 50
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The claims waiting on the caller
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 913
                        status:
                          type: string
                          example: pending
                        kind:
                          type: string
                          enum:
                          - listing
                          - open_shift
                        requested_at:
                          type: string
                          format: date-time
                        shift:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              example: Monday Floor
                            start_time:
                              type: string
                              format: date-time
                              description: In the shift's own time zone.
                            end_time:
                              type: string
                              format: date-time
                              nullable: true
                            location:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                        listed_by:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            avatar_url:
                              type: string
                              nullable: true
                          nullable: true
                          description: Whose listed shift this is (kind listing);
                            null for an open shift.
                        can_approve:
                          type: boolean
                        can_reject:
                          type: boolean
                        cannot_approve_reason:
                          type: string
                          nullable: true
                          example: This shift has already started, so the claim can
                            no longer be approved.
                        reject_needs_reason:
                          type: boolean
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 2
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      next_page:
                        type: integer
                        nullable: true
                      prev_page:
                        type: integer
                        nullable: true
                      degraded:
                        type: boolean
                        description: The queue could not be read; an empty list is
                          then not "nothing waiting".
                        example: false
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            `not_an_approver` — the caller decides no shift claims in this
            organization (they neither manage shifts nor are named by its claim
            approval authority); or Shift Marketplace is off for the business, the
            caller cannot see it, or the token lacks read:shifts.
  "/shift_marketplace/claim_requests/{id}/approve":
    post:
      tags:
      - Shift Marketplace
      summary: Approve a pickup waiting on you
      description: |
        Approves the claim exactly as the Shift Claims page does: the shift is
        assigned to the employee (and taken from the lister for a listing), the
        employee is told, and the approvers' Inbox asks for it close.

        **Scope**: `write:shifts`.
      parameters:
      - name: id
        in: path
        required: true
        description: The claim request's id (an `items[].id` from the list)
        schema:
          type: integer
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The decision landed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: The shift request has been approved.
                  claim_request:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 913
                      status:
                        type: string
                        enum:
                        - approved
                        - rejected
                        example: approved
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`not_an_approver` — the caller decides no shift claims; or
            the token lacks write:shifts."
        '409':
          description: "`not_waiting` — the claim was decided already, or it is not
            waiting on the caller."
        '422':
          description: "`decision_refused` — approving was refused (the shift started
            or was taken, a staffing rule would break); the message says why."
  "/shift_marketplace/claim_requests/{id}/reject":
    post:
      tags:
      - Shift Marketplace
      summary: Decline a pickup waiting on you
      description: |
        Declines the claim exactly as the Shift Claims page does: the employee is
        told, with the reason when one is given, and the approvers' Inbox asks for
        it close.

        **Scope**: `write:shifts` and `destructive:scheduling`.
      parameters:
      - name: id
        in: path
        required: true
        description: The claim request's id (an `items[].id` from the list)
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 1000
                  description: Why — the employee is told it.
                  example: Already covered
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The decision landed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: The shift request has been approved.
                  claim_request:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 913
                      status:
                        type: string
                        enum:
                        - approved
                        - rejected
                        example: approved
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`not_an_approver` — the caller decides no shift claims; `insufficient_permissions`
            — the token lacks write:shifts or destructive:scheduling."
        '409':
          description: "`not_waiting` — the claim was decided already, or it is not
            waiting on the caller."
        '422':
          description: "`decision_refused` — declining was refused (for example a
            reason over 1000 characters); the message says why."
  "/shift_marketplace/direct_offers":
    get:
      tags:
      - Shift Marketplace
      summary: List Direct Offers
      description: |
        Returns direct shift offers (peer-to-peer offers) with filtering options.

        Direct offers are one-to-one shift offers where a user offers their shift
        to a specific colleague, unlike marketplace listings which are public.

        **Authorization**: Users can only see offers they sent or received.
        **Business Scoping**: Results automatically scoped to current business.
      parameters:
      - name: filter_type
        in: query
        description: Filter offers by type (received/sent)
        schema:
          type: string
          enum:
          - received
          - sent
      - name: status
        in: query
        description: Filter by offer status
        schema:
          type: string
          enum:
          - pending
          - accepted
          - declined
          - expired
          - cancelled
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          maximum: 100
      security:
      - BearerAuth: []
      responses:
        '200':
          description: List of direct offers
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: A direct peer-to-peer shift offer
                        properties:
                          id:
                            type: integer
                            example: 1
                          status:
                            type: string
                            enum:
                            - pending
                            - accepted
                            - declined
                            - expired
                            - cancelled
                            example: pending
                          cancellation_reason:
                            type: string
                            nullable: true
                            description: 'Why the app withdrew a cancelled offer (for
                              example, the shift was filled or removed). Null when
                              the offer is not cancelled, or when its sender cancelled
                              it.

                              '
                          notes:
                            type: string
                            nullable: true
                          expires_at:
                            type: string
                            format: date-time
                            nullable: true
                          created_at:
                            type: string
                            format: date-time
                          shift:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              start_time:
                                type: string
                                format: date-time
                              end_time:
                                type: string
                                format: date-time
                              formatted_date:
                                type: string
                              formatted_time:
                                type: string
                              location:
                                type: string
                                nullable: true
                          from_user:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          to_user:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              email:
                                type: string
                              avatar_url:
                                type: string
                                nullable: true
                                description: Full-size profile photo URL (200x200)
                                  or fallback to initials-based avatar
                              avatar_thumbnail_url:
                                type: string
                                nullable: true
                                description: Thumbnail profile photo URL (40x40) optimized
                                  for list views
                          is_sender:
                            type: boolean
                            description: Whether current user sent this offer
                          is_recipient:
                            type: boolean
                            description: Whether current user received this offer
                          can_accept:
                            type: boolean
                            description: Whether current user can accept this offer
                          can_decline:
                            type: boolean
                            description: Whether current user can decline this offer
                          can_cancel:
                            type: boolean
                            description: Whether current user can cancel this offer
                              (sender only, pending offers)
                    meta:
                      description: 'The four pagination values plus Kaminari''s page
                        numbers (this namespace''s own pagination meta: no `has_next_page`
                        / `has_prev_page` flags).'
                      allOf:
                      - type: object
                        description: Pagination metadata
                        properties:
                          total_count:
                            type: integer
                            description: Total number of items
                            example: 150
                          total_pages:
                            type: integer
                            description: Total number of pages
                            example: 6
                          current_page:
                            type: integer
                            description: Current page number
                            example: 1
                          per_page:
                            type: integer
                            description: Items per page
                            example: 25
                        required:
                        - total_count
                        - total_pages
                        - current_page
                        - per_page
                      - type: object
                        properties:
                          next_page:
                            type: integer
                            nullable: true
                            description: The next page number; null on the last page.
                          prev_page:
                            type: integer
                            nullable: true
                            description: The previous page number; null on the first
                              page.
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Authentication required or token invalid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Shift Marketplace
      summary: Create Direct Offer
      description: Send a direct shift offer to a specific colleague
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - direct_offer
              properties:
                direct_offer:
                  type: object
                  required:
                  - shift_id
                  - to_user_id
                  properties:
                    shift_id:
                      type: integer
                      example: 123
                    to_user_id:
                      type: integer
                      example: 456
                    expires_at:
                      type: string
                      format: date-time
                      example: '2025-11-28T10:00:00Z'
                    notes:
                      type: string
                      example: Can you cover my shift?
      responses:
        '200':
          description: Direct offer created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  direct_offer:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      cancellation_reason:
                        type: string
                        nullable: true
                        description: 'Why the app withdrew a cancelled offer (for
                          example, the shift was filled or removed). Null when the
                          offer is not cancelled, or when its sender cancelled it.

                          '
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/shift_marketplace/direct_offers/{id}":
    get:
      tags:
      - Shift Marketplace
      summary: Get Direct Offer Details
      description: View details of a specific direct offer
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Direct offer details
          content:
            application/json:
              schema:
                type: object
                properties:
                  direct_offer:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      cancellation_reason:
                        type: string
                        nullable: true
                        description: 'Why the app withdrew a cancelled offer (for
                          example, the shift was filled or removed). Null when the
                          offer is not cancelled, or when its sender cancelled it.

                          '
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/shift_marketplace/direct_offers/{id}/accept":
    post:
      tags:
      - Shift Marketplace
      summary: Accept Direct Offer
      description: |
        Accept a direct offer (recipient only).
        Transfers the shift to the recipient and updates status.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Offer accepted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      cancellation_reason:
                        type: string
                        nullable: true
                        description: 'Why the app withdrew a cancelled offer (for
                          example, the shift was filled or removed). Null when the
                          offer is not cancelled, or when its sender cancelled it.

                          '
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot accept (expired or already processed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/direct_offers/{id}/decline":
    post:
      tags:
      - Shift Marketplace
      summary: Decline Direct Offer
      description: |
        Decline a direct offer (recipient only)

        Delete-level (owner decision 2026-10-04): requires the `destructive:shift_marketplace`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Offer declined successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      cancellation_reason:
                        type: string
                        nullable: true
                        description: 'Why the app withdrew a cancelled offer (for
                          example, the shift was filled or removed). Null when the
                          offer is not cancelled, or when its sender cancelled it.

                          '
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot decline (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shift_marketplace/direct_offers/{id}/cancel":
    post:
      tags:
      - Shift Marketplace
      summary: Cancel Direct Offer
      description: |
        Cancel a direct offer that you sent (sender only).
        This withdraws the offer before the recipient responds.
        Only pending offers can be cancelled.

        Delete-level (owner decision 2026-10-04): requires the `destructive:shift_marketplace`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Offer cancelled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    description: A direct peer-to-peer shift offer
                    properties:
                      id:
                        type: integer
                        example: 1
                      status:
                        type: string
                        enum:
                        - pending
                        - accepted
                        - declined
                        - expired
                        - cancelled
                        example: pending
                      cancellation_reason:
                        type: string
                        nullable: true
                        description: 'Why the app withdrew a cancelled offer (for
                          example, the shift was filled or removed). Null when the
                          offer is not cancelled, or when its sender cancelled it.

                          '
                      notes:
                        type: string
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      shift:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          start_time:
                            type: string
                            format: date-time
                          end_time:
                            type: string
                            format: date-time
                          formatted_date:
                            type: string
                          formatted_time:
                            type: string
                          location:
                            type: string
                            nullable: true
                      from_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      to_user:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          email:
                            type: string
                          avatar_url:
                            type: string
                            nullable: true
                            description: Full-size profile photo URL (200x200) or
                              fallback to initials-based avatar
                          avatar_thumbnail_url:
                            type: string
                            nullable: true
                            description: Thumbnail profile photo URL (40x40) optimized
                              for list views
                      is_sender:
                        type: boolean
                        description: Whether current user sent this offer
                      is_recipient:
                        type: boolean
                        description: Whether current user received this offer
                      can_accept:
                        type: boolean
                        description: Whether current user can accept this offer
                      can_decline:
                        type: boolean
                        description: Whether current user can decline this offer
                      can_cancel:
                        type: boolean
                        description: Whether current user can cancel this offer (sender
                          only, pending offers)
        '403':
          description: Forbidden - can only cancel offers you sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Cannot cancel (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets":
    get:
      tags:
      - Timesheets
      summary: List user's timesheets
      description: |
        Get a paginated list of timesheets for the authenticated user.
        Supports filtering by status and date ranges.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: Filter by timesheet status
        schema:
          type: string
          enum:
          - pending
          - submitted
          - regional_pending
          - approved
          - rejected
          example: pending
      - name: start_date
        in: query
        description: Filter timesheets starting from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter timesheets ending before this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      responses:
        '200':
          description: List of timesheets
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheets:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        start_date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        end_date:
                          type: string
                          format: date
                          example: '2024-01-21'
                        status:
                          type: string
                          enum:
                          - pending
                          - submitted
                          - regional_pending
                          - approved
                          - rejected
                          example: pending
                        total_hours:
                          type: number
                          format: float
                          example: 40.0
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          description: Premium hours, double time INCLUDED — overtime_only_hours
                            plus double_time_hours.
                          example: 0.0
                        overtime_only_hours:
                          type: number
                          format: float
                          description: Overtime hours excluding double time (the split
                            /pay_information reports).
                          example: 0.0
                        double_time_hours:
                          type: number
                          format: float
                          description: Double-time hours, also counted in overtime_hours.
                          example: 0.0
                        submission_date:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-22T09:00:00Z'
                        auto_submitted:
                          type: boolean
                          description: True while the submission that stands was made
                            by timesheet auto-submit when the due date passed — the
                            hours went in as recorded and the employee did not certify
                            them.
                          example: false
                        auto_submitted_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When timesheet auto-submit first submitted
                            this sheet. Kept after a recall; a sheet is never auto-submitted
                            twice.
                          example:
                        approval_date:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-22T14:30:00Z'
                        rejection_reason:
                          type: string
                          nullable: true
                          description: What the approver wrote when sending the timesheet
                            back (its rejection reason, else the manager's notes)
                            — the text the employee is shown. Null unless the timesheet
                            is rejected.
                          example: Missing Tuesday's clock-out
                        returned_for_correction:
                          type: boolean
                          description: True when the timesheet is rejected and that
                            rejection was a return to the employee for corrections
                            (not an outright rejection).
                          example: false
                        correction_requested_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the employee asked for a correction to
                            this approved timesheet. Null when no request is open;
                            returning the timesheet for correction clears it.
                          example:
                        correction_request_reason:
                          type: string
                          nullable: true
                          description: What the employee wrote when asking for the
                            correction. Null when no request is open.
                          example:
                        can_request_correction:
                          type: boolean
                          description: |
                            Whether the caller may send a correction request on this timesheet
                            (POST /timesheets/{id}/request_correction): their own approved
                            timesheet, not managed in an external system.
                          example: false
                        can_answer_correction:
                          type: boolean
                          description: |
                            Whether the caller may answer the open correction request
                            (POST /timesheets/{id}/close_correction): an approver of the employee
                            who is not the employee or one of their reports.
                          example: false
                        approved_by:
                          type: string
                          nullable: true
                          example: John Manager
                        regional_approved:
                          type: boolean
                          example: false
                        regional_approved_by:
                          type: string
                          nullable: true
                          example: Jane Regional
                        regional_approved_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2024-01-22T16:00:00Z'
                        editable:
                          type: boolean
                          description: Whether the timesheet can be edited
                          example: true
                        submittable:
                          type: boolean
                          description: Whether the timesheet can be submitted
                          example: true
                        source:
                          "$ref": "#/components/schemas/ManagedSource"
                        entries_count:
                          type: integer
                          description: Number of entries in this timesheet
                          example: 5
                        missing_punches_count:
                          type: integer
                          description: Number of entries that still need time entered
                            (missing a clock-in or clock-out). Absences are not counted.
                          example: 0
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-21T18:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Timesheets & Payroll feature not enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/current":
    get:
      tags:
      - Timesheets
      summary: Get current pay period timesheet
      description: |
        Get the timesheet for the current pay period. If no timesheet exists,
        it will be automatically generated based on attendance records and shifts.

        `due_timesheet` is the sheet the employee owes now, when it is a different
        one: an ended period still waiting to be submitted, or a sheet returned for
        corrections — the sheet the phone and web My Timesheets lead with. Null when
        nothing else is due.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current timesheet
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
                  due_timesheet:
                    description: The timesheet due for submission (an ended period
                      not yet submitted, or one returned for corrections), when it
                      is not `timesheet`; null when nothing else is owed.
                    nullable: true
                    allOf:
                    - allOf:
                      - "$ref": "#/components/schemas/Timesheet"
                      - type: object
                        properties:
                          entries:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                          entries_by_date:
                            type: object
                            description: Entries grouped by date
                            additionalProperties:
                              type: array
                              items:
                                "$ref": "#/components/schemas/TimesheetEntry"
                          summary:
                            "$ref": "#/components/schemas/TimesheetSummary"
                          can_return_for_correction:
                            type: boolean
                            description: |
                              Whether the caller may send this approved timesheet back to its
                              employee (POST /timesheets/{id}/return_for_correction). False once
                              its hours have been sent, or are being sent, to payroll.
                            example: false
                          correction_request_explainer:
                            type: string
                            nullable: true
                            description: |
                              The sentence the web and phone show above the correction request
                              box — what a request does for this timesheet, including when its
                              hours are already with payroll. Null when the caller cannot
                              request a correction.
                            example: This timesheet is approved, so it can no longer
                              be edited directly. Describe what's wrong and your approver
                              will be told; they can send it back to you to fix and
                              resubmit.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration or timesheet generation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}":
    get:
      tags:
      - Timesheets
      summary: Get timesheet details
      description: |
        Get detailed information about a specific timesheet including all entries,
        summary statistics, and approval status.

        Answers the caller's own timesheet. A caller whose token also holds
        `approve:timesheets` can read a team member's timesheet when they may
        approve for that employee: the rule the Team Timesheets review page
        uses. This is how an approver reads an open correction request before
        answering it. Any other timesheet answers 404 `timesheet_not_found`.

        **Token scope:** `read:timesheets` (plus `approve:timesheets` for a team
        member's timesheet).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Timesheet details
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Timesheet not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    put:
      tags:
      - Timesheets
      summary: Update timesheet entries
      description: |
        Update timesheet entries with new times or notes. Only editable timesheets
        (pending or rejected status) can be updated.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                timesheet_entries:
                  type: object
                  description: Map of entry IDs to update parameters
                  additionalProperties:
                    type: object
                    required:
                    - edit_reason
                    properties:
                      start_time:
                        type: string
                        format: time
                        example: '09:00:00'
                      end_time:
                        type: string
                        format: time
                        example: '17:00:00'
                      edit_reason:
                        type: string
                        description: Required whenever a punch actually changes —
                          the reason is stored on the entry and in its edit history.
                          An entry whose times do not move is a no-op and needs none;
                          a changed entry sent without one is refused (422 edit_reason_required)
                          and no entry in the request is written.
                        example: Corrected clock-in time
                timesheet:
                  type: object
                  properties:
                    notes:
                      type: string
                      description: The employee's own note on the timesheet. This
                        endpoint is scoped to the caller's own timesheets, so manager_notes
                        — the approver's field, shown back to the employee as the
                        rejection reason — is deliberately not writable here.
                      example: Updated per employee request
      responses:
        '200':
          description: Timesheet updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Update failed, timesheet not editable, or a changed entry was
            sent without an edit_reason (edit_reason_required — the error details
            name every offending entry id, and nothing in the request is written).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/submit":
    post:
      tags:
      - Timesheets
      summary: Submit timesheet for approval
      description: |
        Submit a timesheet for manager approval. The timesheet must be in pending
        or rejected status and have at least one entry.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Timesheet submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
                  submission:
                    type: object
                    properties:
                      resubmitted:
                        type: boolean
                        description: Whether this was a resubmission after rejection
                      accuracy_score:
                        type: number
                        format: float
                        description: Calculated accuracy score (if available)
                        example: 95.5
                      score_error:
                        type: string
                        description: Error message if score calculation failed
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Timesheet cannot be submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/reject":
    post:
      tags:
      - Timesheets
      summary: Reject a submitted timesheet
      description: |
        An approver rejects a timesheet in this business that is waiting for a
        decision (`submitted` or `regional_pending`) and sends it back to the
        employee, who is told the reason. The caller must hold the approve
        permission for timesheets over the employee (403 `forbidden`).

        **Token scopes:** `approve:timesheets` and `destructive:timekeeping` —
        rejecting a timesheet is a final act (owner decision 2026-10-04, #6721),
        the scope tool_reject_timesheet asks on Ask AI and MCP. A token missing
        either is refused with 403 `insufficient_permissions` ("Required scope:
        …").
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rejection_reason
              properties:
                rejection_reason:
                  type: string
                  description: Shown to the employee. Required.
                  example: Missing Tuesday's clock-out
      responses:
        '200':
          description: Timesheet rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
                  rejection:
                    type: object
                    properties:
                      reason:
                        type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` (the token lacks `approve:timesheets`
            or `destructive:timekeeping`) or `forbidden` ("You don''t have permission
            to reject this timesheet").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`timesheet_not_found` — no such timesheet in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`already_decided` — another approver decided the timesheet
            first; `details.current_status` is its status now."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`rejection_reason_required` (no reason given) or `not_rejectable`
            (the timesheet is not `submitted` or `regional_pending`, or is managed
            by an external system; `details.current_status` says which status it is
            in)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/request_correction":
    post:
      tags:
      - Timesheets
      summary: Ask for a correction to your approved timesheet
      description: |
        The employee asks for a correction to their own APPROVED timesheet,
        which can no longer be edited directly. The timesheet's approvers (or,
        once they have left, whoever may approve for the employee) get an Inbox
        ask and an email with the reason. They answer it by returning the
        timesheet for correction or by closing the request with no change. A
        second request while one is open is added below the first.
        Same rules as the web and phone "Request a correction".

        **Token scope:** `write:timesheets`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
                  maxLength: 1000
                  description: What needs to be corrected. The approver reads it.
                  example: Tuesday's clock-out should be 6:30 PM, not 5:30 PM
      responses:
        '200':
          description: The approvers were told.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
                  correction_request:
                    type: object
                    properties:
                      sent_to:
                        type: array
                        description: The approvers who were told.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                      saved:
                        type: boolean
                        description: False when the approvers were told but the request
                          could not be kept on the timesheet.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` (the token lacks `write:timesheets`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`timesheet_not_found` — not one of your timesheets."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`not_approved` (only an approved timesheet takes a request;
            edit this one instead), `externally_managed` (the timesheet is managed
            in another system, whatever its state; request the correction there —
            `details.managed_by` names it), `reason_required`, `nobody_told` (approvers
            were found but none could be reached) or `no_approver` (nobody may answer
            it). `details.suggestion` says what to do."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/close_correction":
    post:
      tags:
      - Timesheets
      summary: Close an employee's correction request with no change
      description: |
        The approver's answer to an employee's correction request when nothing
        needs changing. The request leaves the timesheet, the approvers' Inbox
        asks close, and the employee is told, with the optional reply. When the
        hours are already with payroll the employee is told the correction is
        made in payroll. The caller must hold the approve permission for
        timesheets over the employee and must not be the employee or one of
        their reports — the rule `can_answer_correction` reports.

        **Token scope:** `approve:timesheets`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reply:
                  type: string
                  maxLength: 1000
                  description: Optional. Shown to the employee.
                  example: Checked the clock records — 5:30 PM is right.
      responses:
        '200':
          description: The request is closed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
                  correction_request:
                    type: object
                    properties:
                      closed:
                        type: boolean
                      employee_told:
                        type: boolean
                      hours_in_payroll:
                        type: boolean
                        description: True when the employee was told the correction
                          is made in payroll.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` (the token lacks `approve:timesheets`)
            or `forbidden` (the caller may not answer this request)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`timesheet_not_found` — no such timesheet in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`no_open_request` — the timesheet has no open correction request."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/return_for_correction":
    post:
      tags:
      - Timesheets
      summary: Send an approved timesheet back to the employee to correct
      description: |
        Sends an APPROVED timesheet back to its employee to correct and
        resubmit — the approver's other answer to a correction request. Clears
        the open request and closes the approvers' asks; the employee is told
        the reason. Refused once the timesheet's hours have been sent, or are
        being sent, to payroll. Same rules as Return for correction on the
        Team Timesheets page.

        **Token scopes:** `approve:timesheets` and `destructive:timekeeping`, as
        for reject.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rejection_reason
              properties:
                rejection_reason:
                  type: string
                  description: What needs correcting. Shown to the employee.
                  example: Fix Tuesday's clock-out and resubmit
      responses:
        '200':
          description: The timesheet was returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  timesheet:
                    allOf:
                    - "$ref": "#/components/schemas/Timesheet"
                    - type: object
                      properties:
                        entries:
                          type: array
                          items:
                            "$ref": "#/components/schemas/TimesheetEntry"
                        entries_by_date:
                          type: object
                          description: Entries grouped by date
                          additionalProperties:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TimesheetEntry"
                        summary:
                          "$ref": "#/components/schemas/TimesheetSummary"
                        can_return_for_correction:
                          type: boolean
                          description: |
                            Whether the caller may send this approved timesheet back to its
                            employee (POST /timesheets/{id}/return_for_correction). False once
                            its hours have been sent, or are being sent, to payroll.
                          example: false
                        correction_request_explainer:
                          type: string
                          nullable: true
                          description: |
                            The sentence the web and phone show above the correction request
                            box — what a request does for this timesheet, including when its
                            hours are already with payroll. Null when the caller cannot
                            request a correction.
                          example: This timesheet is approved, so it can no longer
                            be edited directly. Describe what's wrong and your approver
                            will be told; they can send it back to you to fix and
                            resubmit.
                  rejection:
                    type: object
                    properties:
                      reason:
                        type: string
                      returned_for_correction:
                        type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` (the token lacks a scope), `forbidden`
            (no approve permission over the employee, or the caller is the employee)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`timesheet_not_found` — no such timesheet in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`rejection_reason_required`, `externally_managed` (managed
            in another system), or `not_returnable` (not approved, or its hours are
            with payroll; `details.suggestion` says which)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheets/{id}/entries":
    get:
      tags:
      - Timesheets
      summary: Get timesheet entries
      description: |
        Get all entries for a specific timesheet with detailed information
        including associated shifts and attendance records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Timesheet entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        start_time:
                          type: string
                          format: time
                          nullable: true
                          example: '09:00:00'
                        end_time:
                          type: string
                          format: time
                          nullable: true
                          example: '17:00:00'
                        hours:
                          type: number
                          format: float
                          nullable: true
                          example: 8.0
                        edited:
                          type: boolean
                          description: Whether this entry has been manually edited
                          example: false
                        edit_notes:
                          type: string
                          nullable: true
                          description: Notes about edits made to this entry
                          example: Corrected clock-in time
                        missing_punch:
                          type: boolean
                          description: Whether this past entry still needs a clock-in
                            or clock-out entered. False for an absence (see absent).
                          example: false
                        absent:
                          type: boolean
                          description: Whether this entry is an absence — a scheduled
                            day with no times and no hours — which has nothing to
                            enter.
                          example: false
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          example: pending
                        attendance_record_id:
                          type: integer
                          nullable: true
                          description: Associated attendance record ID
                          example: 789
                        shift_id:
                          type: integer
                          nullable: true
                          description: Associated shift ID
                          example: 101
                        shift:
                          type: object
                          nullable: true
                          description: Associated shift information
                          properties:
                            id:
                              type: integer
                              example: 101
                            title:
                              type: string
                              example: Morning Shift
                            location:
                              type: string
                              nullable: true
                              example: Main Office
                            is_ad_hoc:
                              type: boolean
                              example: false
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T09:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T17:00:00Z'
                  timesheet_summary:
                    type: object
                    properties:
                      total_hours:
                        type: number
                        format: float
                        example: 40.0
                      regular_hours:
                        type: number
                        format: float
                        example: 40.0
                      overtime_hours:
                        type: number
                        format: float
                        description: Premium hours, double time INCLUDED — overtime_only_hours
                          plus double_time_hours.
                        example: 0.0
                      overtime_only_hours:
                        type: number
                        format: float
                        description: Overtime hours excluding double time.
                        example: 0.0
                      double_time_hours:
                        type: number
                        format: float
                        description: Double-time hours, also counted in overtime_hours.
                        example: 0.0
                      total_entries:
                        type: integer
                        example: 5
                      missing_punches:
                        type: integer
                        description: Entries that still need time entered. Absences
                          are not counted.
                        example: 0
                      edited_entries:
                        type: integer
                        example: 1
                      days_with_entries:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Timesheet not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheet_entries":
    get:
      tags:
      - Timesheet Entries
      summary: List timesheet entries
      description: |
        Get a paginated list of timesheet entries across all user's timesheets
        or for a specific timesheet. Supports various filters.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: timesheet_id
        in: query
        description: Filter entries for specific timesheet
        schema:
          type: integer
          example: 123
      - name: start_date
        in: query
        description: Filter entries from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter entries to this date
        schema:
          type: string
          format: date
          example: '2024-01-31'
      - name: status
        in: query
        description: Filter by entry status
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
      - name: edited
        in: query
        description: Filter by edited status
        schema:
          type: boolean
          example: true
      - name: missing_punch
        in: query
        description: 'Filter by missing punch status — the entries whose missing_punch
          flag is true: a past entry still missing a clock-in or clock-out that is
          not an absence. false returns every other entry, absences included.'
        schema:
          type: boolean
          example: false
      responses:
        '200':
          description: List of timesheet entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        start_time:
                          type: string
                          format: time
                          nullable: true
                          example: '09:00:00'
                        end_time:
                          type: string
                          format: time
                          nullable: true
                          example: '17:00:00'
                        hours:
                          type: number
                          format: float
                          nullable: true
                          example: 8.0
                        edited:
                          type: boolean
                          description: Whether this entry has been manually edited
                          example: false
                        edit_notes:
                          type: string
                          nullable: true
                          description: Notes about edits made to this entry
                          example: Corrected clock-in time
                        missing_punch:
                          type: boolean
                          description: Whether this past entry still needs a clock-in
                            or clock-out entered. False for an absence (see absent).
                          example: false
                        absent:
                          type: boolean
                          description: Whether this entry is an absence — a scheduled
                            day with no times and no hours — which has nothing to
                            enter.
                          example: false
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          example: pending
                        attendance_record_id:
                          type: integer
                          nullable: true
                          description: Associated attendance record ID
                          example: 789
                        shift_id:
                          type: integer
                          nullable: true
                          description: Associated shift ID
                          example: 101
                        shift:
                          type: object
                          nullable: true
                          description: Associated shift information
                          properties:
                            id:
                              type: integer
                              example: 101
                            title:
                              type: string
                              example: Morning Shift
                            location:
                              type: string
                              nullable: true
                              example: Main Office
                            is_ad_hoc:
                              type: boolean
                              example: false
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T09:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T17:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Timesheet Entries
      summary: Create manual timesheet entry
      description: |
        Create a manual timesheet entry for times not captured through
        attendance records. The timesheet must be editable.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - timesheet_entry
              properties:
                timesheet_entry:
                  type: object
                  required:
                  - timesheet_id
                  - date
                  - start_time
                  - end_time
                  properties:
                    timesheet_id:
                      type: integer
                      example: 123
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      description: Hours worked (calculated if not provided)
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Manual entry for missed punch
      responses:
        '201':
          description: Timesheet entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  entry:
                    allOf:
                    - "$ref": "#/components/schemas/TimesheetEntry"
                    - type: object
                      properties:
                        timesheet:
                          type: object
                          description: Associated timesheet information
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              example: pending
                        edit_history:
                          type: array
                          description: History of edits made to this entry
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: start_time
                              original_value:
                                type: string
                                example: '08:45:00'
                              new_value:
                                type: string
                                example: '09:00:00'
                              reason:
                                type: string
                                example: Employee correction
                              edited_by:
                                type: string
                                example: John Employee
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                        attendance_record:
                          type: object
                          nullable: true
                          description: Associated attendance record details
                          properties:
                            id:
                              type: integer
                              example: 789
                            check_in_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T09:00:00Z'
                            check_out_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T17:00:00Z'
                            status:
                              type: string
                              example: completed
                            requires_review:
                              type: boolean
                              example: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: "`duplicate_entry` — an entry with exactly these punches (date,
            start_time, end_time) is already on the timesheet, typically a retried
            POST sent without an `Idempotency-Key`. Nothing was created; `details.existing_entry_id`
            names the entry that holds them."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation failed or timesheet not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/timesheet_entries/{id}":
    get:
      tags:
      - Timesheet Entries
      summary: Get timesheet entry details
      description: |
        Get detailed information about a specific timesheet entry including
        edit history and associated records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet entry ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Timesheet entry details
          content:
            application/json:
              schema:
                type: object
                properties:
                  entry:
                    allOf:
                    - "$ref": "#/components/schemas/TimesheetEntry"
                    - type: object
                      properties:
                        timesheet:
                          type: object
                          description: Associated timesheet information
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              example: pending
                        edit_history:
                          type: array
                          description: History of edits made to this entry
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: start_time
                              original_value:
                                type: string
                                example: '08:45:00'
                              new_value:
                                type: string
                                example: '09:00:00'
                              reason:
                                type: string
                                example: Employee correction
                              edited_by:
                                type: string
                                example: John Employee
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                        attendance_record:
                          type: object
                          nullable: true
                          description: Associated attendance record details
                          properties:
                            id:
                              type: integer
                              example: 789
                            check_in_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T09:00:00Z'
                            check_out_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T17:00:00Z'
                            status:
                              type: string
                              example: completed
                            requires_review:
                              type: boolean
                              example: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Timesheet entry not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    put:
      tags:
      - Timesheet Entries
      summary: Update timesheet entry
      description: |
        Update a timesheet entry with new times or notes. The associated
        timesheet must be editable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet entry ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - timesheet_entry
              properties:
                timesheet_entry:
                  type: object
                  properties:
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Corrected end time
                edit_reason:
                  type: string
                  description: Reason for the edit (for audit trail)
                  example: Employee requested time correction
      responses:
        '200':
          description: Timesheet entry updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  entry:
                    allOf:
                    - "$ref": "#/components/schemas/TimesheetEntry"
                    - type: object
                      properties:
                        timesheet:
                          type: object
                          description: Associated timesheet information
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              example: pending
                        edit_history:
                          type: array
                          description: History of edits made to this entry
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: start_time
                              original_value:
                                type: string
                                example: '08:45:00'
                              new_value:
                                type: string
                                example: '09:00:00'
                              reason:
                                type: string
                                example: Employee correction
                              edited_by:
                                type: string
                                example: John Employee
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                        attendance_record:
                          type: object
                          nullable: true
                          description: Associated attendance record details
                          properties:
                            id:
                              type: integer
                              example: 789
                            check_in_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T09:00:00Z'
                            check_out_time:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2024-01-15T17:00:00Z'
                            status:
                              type: string
                              example: completed
                            requires_review:
                              type: boolean
                              example: false
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Update failed or timesheet not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Timesheet Entries
      summary: Delete timesheet entry
      description: |
        Delete a manual timesheet entry. Only entries not tied to attendance
        records can be deleted, and the timesheet must be editable.

        **Token scopes:** `write:timesheets` and `destructive:timekeeping` —
        deleting an entry is a delete-level act (#7246).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Timesheet entry ID
        schema:
          type: integer
          example: 456
      responses:
        '204':
          description: Timesheet entry deleted successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot delete entry (tied to attendance or timesheet not editable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/summary":
    get:
      tags:
      - Pay Information
      summary: Get current pay period summary
      description: |
        Get a summary of the current pay period including hours worked,
        estimated pay, and timesheet status.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Pay period summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_summary:
                    type: object
                    properties:
                      pay_period:
                        type: object
                        properties:
                          start_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          end_date:
                            type: string
                            format: date
                            example: '2024-01-21'
                          period_type:
                            type: string
                            enum:
                            - weekly
                            - bi-weekly
                            - semi-monthly
                            - monthly
                            example: weekly
                      hours:
                        type: object
                        properties:
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                      estimated_pay:
                        type: object
                        properties:
                          regular_pay:
                            type: number
                            format: float
                            example: 1000.0
                          overtime_pay:
                            type: number
                            format: float
                            example: 93.75
                          total_pay:
                            type: number
                            format: float
                            example: 1093.75
                          hourly_rate:
                            type: number
                            format: float
                            example: 25.0
                          overtime_rate:
                            type: number
                            format: float
                            example: 37.5
                      timesheet_status:
                        type: string
                        enum:
                        - not_created
                        - pending
                        - submitted
                        - approved
                        - rejected
                        example: pending
                      last_updated:
                        type: string
                        format: date-time
                        example: '2024-01-20T10:30:00Z'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/history":
    get:
      tags:
      - Pay Information
      summary: Get pay history
      description: |
        Get historical pay information from approved timesheets and paychecks.
        Includes both estimated pay from timesheets and actual pay from paychecks.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Pay history
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_history:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum:
                          - timesheet
                          - paycheck
                          example: timesheet
                        id:
                          type: integer
                          example: 123
                        period_start:
                          type: string
                          format: date
                          example: '2024-01-15'
                        period_end:
                          type: string
                          format: date
                          example: '2024-01-21'
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 2.5
                        total_hours:
                          type: number
                          format: float
                          example: 42.5
                        status:
                          type: string
                          description: Status (for timesheets)
                          example: approved
                        approved_at:
                          type: string
                          format: date-time
                          description: Approval date (for timesheets)
                          example: '2024-01-22T14:30:00Z'
                        estimated_pay:
                          type: number
                          format: float
                          description: Estimated pay (for timesheets)
                          example: 1093.75
                        gross_pay:
                          type: number
                          format: float
                          description: Gross pay (for paychecks)
                          example: 1093.75
                        net_pay:
                          type: number
                          format: float
                          description: Net pay (for paychecks)
                          example: 850.0
                        pay_date:
                          type: string
                          format: date
                          description: Pay date (for paychecks)
                          example: '2024-01-26'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/pay_information/current_period":
    get:
      tags:
      - Pay Information
      summary: Get current pay period details
      description: |
        Get detailed information about the current pay period including
        timesheet status, estimated pay, and next payday.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current pay period details
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/next_payday":
    get:
      tags:
      - Pay Information
      summary: Get next payday information
      description: |
        Calculate and return the next payday date based on current
        pay period and business pay schedule.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Next payday information
          content:
            application/json:
              schema:
                type: object
                properties:
                  next_payday:
                    type: object
                    properties:
                      date:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until:
                        type: integer
                        example: 5
                      period_start:
                        type: string
                        format: date
                        example: '2024-01-15'
                      period_end:
                        type: string
                        format: date
                        example: '2024-01-21'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_information/ytd_summary":
    get:
      tags:
      - Pay Information
      summary: Get year-to-date summary
      description: |
        Get year-to-date summary including total hours worked,
        estimated and actual pay, and timesheet statistics.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Year-to-date summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  ytd_summary:
                    type: object
                    properties:
                      year:
                        type: integer
                        example: 2024
                      period:
                        type: object
                        properties:
                          start_date:
                            type: string
                            format: date
                            example: '2024-01-01'
                          end_date:
                            type: string
                            format: date
                            example: '2024-12-31'
                          days_elapsed:
                            type: integer
                            example: 20
                          days_remaining:
                            type: integer
                            example: 345
                      hours:
                        type: object
                        properties:
                          regular_hours:
                            type: number
                            format: float
                            example: 160.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 10.0
                          total_hours:
                            type: number
                            format: float
                            example: 170.0
                          average_weekly_hours:
                            type: number
                            format: float
                            example: 42.5
                      estimated_pay:
                        type: object
                        properties:
                          regular_pay:
                            type: number
                            format: float
                            example: 4000.0
                          overtime_pay:
                            type: number
                            format: float
                            example: 375.0
                          total_pay:
                            type: number
                            format: float
                            example: 4375.0
                      actual_pay:
                        type: object
                        properties:
                          gross_pay:
                            type: number
                            format: float
                            example: 4200.0
                          net_pay:
                            type: number
                            format: float
                            example: 3200.0
                          paychecks_count:
                            type: integer
                            example: 4
                      timesheets:
                        type: object
                        properties:
                          total_count:
                            type: integer
                            example: 4
                          approved_count:
                            type: integer
                            example: 3
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/paychecks":
    get:
      tags:
      - Paychecks
      summary: List user's paychecks
      description: |
        Get a paginated list of paychecks for the authenticated user.
        Requires Payroll Connect to be enabled and configured.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: start_date
        in: query
        description: Filter paychecks from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter paychecks to this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: year
        in: query
        description: Filter paychecks by year
        schema:
          type: integer
          example: 2024
      responses:
        '200':
          description: List of paychecks
          content:
            application/json:
              schema:
                type: object
                properties:
                  paychecks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        pay_date:
                          type: string
                          format: date
                          example: '2024-01-26'
                        pay_period_start:
                          type: string
                          format: date
                          example: '2024-01-15'
                        pay_period_end:
                          type: string
                          format: date
                          example: '2024-01-21'
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 2.5
                        total_hours:
                          type: number
                          format: float
                          example: 42.5
                        gross_pay:
                          type: number
                          format: float
                          example: 1093.75
                        net_pay:
                          type: number
                          format: float
                          example: 850.0
                        pay_frequency:
                          type: string
                          enum:
                          - weekly
                          - bi-weekly
                          - semi-monthly
                          - monthly
                          example: weekly
                        has_file:
                          type: boolean
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Payroll Connect not enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/paychecks/recent":
    get:
      tags:
      - Paychecks
      summary: Get recent paychecks
      description: 'Get the most recent paychecks (last 6 months, up to 12 paychecks).

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Recent paychecks
          content:
            application/json:
              schema:
                type: object
                properties:
                  recent_paychecks:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        pay_date:
                          type: string
                          format: date
                          example: '2024-01-26'
                        pay_period_start:
                          type: string
                          format: date
                          example: '2024-01-15'
                        pay_period_end:
                          type: string
                          format: date
                          example: '2024-01-21'
                        regular_hours:
                          type: number
                          format: float
                          example: 40.0
                        overtime_hours:
                          type: number
                          format: float
                          example: 2.5
                        total_hours:
                          type: number
                          format: float
                          example: 42.5
                        gross_pay:
                          type: number
                          format: float
                          example: 1093.75
                        net_pay:
                          type: number
                          format: float
                          example: 850.0
                        pay_frequency:
                          type: string
                          enum:
                          - weekly
                          - bi-weekly
                          - semi-monthly
                          - monthly
                          example: weekly
                        has_file:
                          type: boolean
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-26T08:00:00Z'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Payroll Connect not enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/paychecks/{id}":
    get:
      tags:
      - Paychecks
      summary: Get paycheck details
      description: |
        Get detailed paycheck information including earnings, deductions,
        taxes, and year-to-date totals.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Paycheck ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Paycheck details
          content:
            application/json:
              schema:
                type: object
                properties:
                  paycheck:
                    allOf:
                    - "$ref": "#/components/schemas/Paycheck"
                    - type: object
                      properties:
                        earnings:
                          type: object
                          properties:
                            regular_pay:
                              type: number
                              format: float
                              example: 1000.0
                            overtime_pay:
                              type: number
                              format: float
                              example: 93.75
                            holiday_pay:
                              type: number
                              format: float
                              example: 0.0
                            sick_pay:
                              type: number
                              format: float
                              example: 0.0
                            vacation_pay:
                              type: number
                              format: float
                              example: 0.0
                            bonus:
                              type: number
                              format: float
                              example: 0.0
                            commission:
                              type: number
                              format: float
                              example: 0.0
                            other_earnings:
                              type: number
                              format: float
                              example: 0.0
                            gross_pay:
                              type: number
                              format: float
                              example: 1093.75
                        deductions:
                          type: object
                          properties:
                            health_insurance:
                              type: number
                              format: float
                              example: 125.0
                            dental_insurance:
                              type: number
                              format: float
                              example: 15.0
                            vision_insurance:
                              type: number
                              format: float
                              example: 5.0
                            life_insurance:
                              type: number
                              format: float
                              example: 10.0
                            retirement_401k:
                              type: number
                              format: float
                              example: 50.0
                            retirement_roth:
                              type: number
                              format: float
                              example: 0.0
                            hsa:
                              type: number
                              format: float
                              example: 25.0
                            fsa:
                              type: number
                              format: float
                              example: 0.0
                            parking:
                              type: number
                              format: float
                              example: 20.0
                            union_dues:
                              type: number
                              format: float
                              example: 0.0
                            other_deductions:
                              type: number
                              format: float
                              example: 0.0
                            total_deductions:
                              type: number
                              format: float
                              example: 250.0
                        taxes:
                          type: object
                          properties:
                            federal_income_tax:
                              type: number
                              format: float
                              example: 150.0
                            state_income_tax:
                              type: number
                              format: float
                              example: 50.0
                            local_income_tax:
                              type: number
                              format: float
                              example: 10.0
                            social_security:
                              type: number
                              format: float
                              example: 67.81
                            medicare:
                              type: number
                              format: float
                              example: 15.86
                            unemployment_tax:
                              type: number
                              format: float
                              example: 0.0
                            disability_tax:
                              type: number
                              format: float
                              example: 5.0
                            other_taxes:
                              type: number
                              format: float
                              example: 0.0
                            total_taxes:
                              type: number
                              format: float
                              example: 298.67
                        employer_contributions:
                          type: object
                          properties:
                            health_insurance:
                              type: number
                              format: float
                              example: 200.0
                            retirement_match:
                              type: number
                              format: float
                              example: 25.0
                            social_security:
                              type: number
                              format: float
                              example: 67.81
                            medicare:
                              type: number
                              format: float
                              example: 15.86
                            unemployment:
                              type: number
                              format: float
                              example: 6.56
                            workers_comp:
                              type: number
                              format: float
                              example: 10.94
                            other_contributions:
                              type: number
                              format: float
                              example: 0.0
                            total_contributions:
                              type: number
                              format: float
                              example: 326.17
                        year_to_date:
                          type: object
                          properties:
                            gross_pay:
                              type: number
                              format: float
                              example: 4375.0
                            net_pay:
                              type: number
                              format: float
                              example: 3400.0
                            regular_hours:
                              type: number
                              format: float
                              example: 160.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 10.0
                            total_hours:
                              type: number
                              format: float
                              example: 170.0
                            federal_tax:
                              type: number
                              format: float
                              example: 600.0
                            state_tax:
                              type: number
                              format: float
                              example: 200.0
                            social_security:
                              type: number
                              format: float
                              example: 271.25
                            medicare:
                              type: number
                              format: float
                              example: 63.44
                        connection:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: ADP Payroll Connection
                            provider:
                              type: string
                              example: adp
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Paycheck not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/paychecks/{id}/download":
    get:
      tags:
      - Paychecks
      summary: Download paycheck stub
      description: 'Get download URL for paycheck stub file (if available).

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Paycheck ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Download information
          content:
            application/json:
              schema:
                type: object
                properties:
                  download_url:
                    type: string
                    format: uri
                    example: https://example.com/download/paycheck_123.pdf
                  filename:
                    type: string
                    example: paycheck_123_20240115.pdf
                  content_type:
                    type: string
                    example: application/pdf
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Paycheck file not available
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods":
    get:
      tags:
      - Pay Periods
      summary: List pay periods
      description: |
        Get a list of pay periods for a specified date range.
        Useful for displaying pay period calendar and history.

        Periods are computed from the business's pay-period settings — the same
        calendar `/pay_periods/current` and `/pay_periods/next` return: weekly
        periods start on the configured start day, biweekly periods every 14 days
        from the configured reference start date, semi-monthly periods on the
        configured days of the month (clamped for short months), monthly periods
        on the 1st.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: start_date
        in: query
        description: Start date for pay period range
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: End date for pay period range
        schema:
          type: string
          format: date
          example: '2024-12-31'
      responses:
        '200':
          description: List of pay periods
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_periods:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          example: 2024-01-15_2024-01-21
                        start_date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        end_date:
                          type: string
                          format: date
                          example: '2024-01-21'
                        period_type:
                          type: string
                          enum:
                          - weekly
                          - bi-weekly
                          - semi-monthly
                          - monthly
                          example: weekly
                        days_in_period:
                          type: integer
                          example: 7
                        is_current:
                          type: boolean
                          example: true
                        is_future:
                          type: boolean
                          example: false
                        is_past:
                          type: boolean
                          example: false
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/current":
    get:
      tags:
      - Pay Periods
      summary: Get current pay period
      description: |
        Get detailed information about the current pay period including
        associated timesheet, estimated pay, and completion status.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current pay period
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/next":
    get:
      tags:
      - Pay Periods
      summary: Get next pay period
      description: 'Get information about the next pay period for planning purposes.

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Next pay period
          content:
            application/json:
              schema:
                type: object
                properties:
                  next_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: No pay period configuration found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/{id}":
    get:
      tags:
      - Pay Periods
      summary: Get pay period details
      description: |
        Get detailed information about a specific pay period.
        ID format: "YYYY-MM-DD_YYYY-MM-DD" (start_date_end_date).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Pay period ID (start_date_end_date format)
        schema:
          type: string
          example: 2024-01-15_2024-01-21
      responses:
        '200':
          description: Pay period details
          content:
            application/json:
              schema:
                type: object
                properties:
                  pay_period:
                    type: object
                    properties:
                      id:
                        type: string
                        example: 2024-01-15_2024-01-21
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-01-21'
                      period_type:
                        type: string
                        enum:
                        - weekly
                        - bi-weekly
                        - semi-monthly
                        - monthly
                        example: weekly
                      days_in_period:
                        type: integer
                        example: 7
                      is_current:
                        type: boolean
                        example: true
                      is_future:
                        type: boolean
                        example: false
                      is_past:
                        type: boolean
                        example: false
                      payday:
                        type: string
                        format: date
                        example: '2024-01-26'
                      days_until_payday:
                        type: integer
                        description: Days until payday (for current period)
                        example: 5
                      days_remaining:
                        type: integer
                        description: Days remaining in period (for current period)
                        example: 2
                      timesheet:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          status:
                            type: string
                            enum:
                            - pending
                            - submitted
                            - approved
                            - rejected
                            example: pending
                          total_hours:
                            type: number
                            format: float
                            example: 42.5
                          regular_hours:
                            type: number
                            format: float
                            example: 40.0
                          overtime_hours:
                            type: number
                            format: float
                            example: 2.5
                          submission_date:
                            type: string
                            format: date-time
                            example: '2024-01-21T17:00:00Z'
                          approval_date:
                            type: string
                            format: date-time
                            example: '2024-01-22T09:00:00Z'
                          editable:
                            type: boolean
                            example: true
                          submittable:
                            type: boolean
                            example: true
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      work_days:
                        type: integer
                        example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '400':
          description: Invalid pay period ID format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/pay_periods/{id}/summary":
    get:
      tags:
      - Pay Periods
      summary: Get pay period summary
      description: |
        Get comprehensive summary of a pay period including hours from
        different sources, attendance statistics, and pay calculations.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Pay period ID (start_date_end_date format)
        schema:
          type: string
          example: 2024-01-15_2024-01-21
      responses:
        '200':
          description: Pay period summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  period_summary:
                    type: object
                    properties:
                      period:
                        "$ref": "#/components/schemas/PayPeriod"
                      hours:
                        type: object
                        properties:
                          timesheet:
                            type: object
                            properties:
                              regular:
                                type: number
                                format: float
                                example: 40.0
                              overtime:
                                type: number
                                format: float
                                example: 2.5
                              total:
                                type: number
                                format: float
                                example: 42.5
                          attendance:
                            type: object
                            properties:
                              regular:
                                type: number
                                format: float
                                example: 39.5
                              overtime:
                                type: number
                                format: float
                                example: 2.0
                              total:
                                type: number
                                format: float
                                example: 41.5
                          scheduled:
                            type: number
                            format: float
                            example: 40.0
                          variance:
                            type: object
                            properties:
                              timesheet_vs_scheduled:
                                type: number
                                format: float
                                example: 2.5
                              attendance_vs_scheduled:
                                type: number
                                format: float
                                example: 1.5
                              timesheet_vs_attendance:
                                type: number
                                format: float
                                example: 1.0
                      attendance:
                        type: object
                        properties:
                          total_records:
                            type: integer
                            example: 5
                          completed_shifts:
                            type: integer
                            example: 5
                          missed_shifts:
                            type: integer
                            example: 0
                          late_arrivals:
                            type: integer
                            example: 1
                          early_departures:
                            type: integer
                            example: 0
                      estimated_pay:
                        type: number
                        format: float
                        example: 1093.75
                      timesheet_status:
                        type: string
                        enum:
                        - not_created
                        - pending
                        - submitted
                        - approved
                        - rejected
                        example: pending
                      completion_percentage:
                        type: number
                        format: float
                        example: 85.7
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '400':
          description: Invalid pay period ID format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/manual_time_entries":
    get:
      tags:
      - Manual Time Entries
      summary: List manual time entries
      description: |
        Get a list of manual time entries (entries not tied to attendance records).
        These are entries created manually by employees for missed punches or corrections.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: timesheet_id
        in: query
        description: Filter entries for specific timesheet
        schema:
          type: integer
          example: 123
      - name: start_date
        in: query
        description: Filter entries from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter entries to this date
        schema:
          type: string
          format: date
          example: '2024-01-31'
      - name: status
        in: query
        description: Filter by entry status
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
      - name: entry_type
        in: query
        description: Filter by entry type
        schema:
          type: string
          enum:
          - missing_punch
          - complete
          - overtime
          example: missing_punch
      responses:
        '200':
          description: List of manual time entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 456
                        timesheet_id:
                          type: integer
                          example: 123
                        date:
                          type: string
                          format: date
                          example: '2024-01-15'
                        start_time:
                          type: string
                          format: time
                          example: '09:00:00'
                        end_time:
                          type: string
                          format: time
                          example: '17:00:00'
                        hours:
                          type: number
                          format: float
                          example: 8.0
                        entry_type:
                          type: string
                          enum:
                          - missing_punch
                          - regular
                          - overtime
                          example: missing_punch
                        edit_notes:
                          type: string
                          example: Manual entry for missed punch
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - rejected
                          example: pending
                        shift:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 789
                            title:
                              type: string
                              example: Morning Shift
                            location:
                              type: string
                              example: Main Office
                        timesheet:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 123
                            start_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            end_date:
                              type: string
                              format: date
                              example: '2024-01-21'
                            status:
                              type: string
                              enum:
                              - pending
                              - submitted
                              - approved
                              - rejected
                              example: pending
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T18:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-15T18:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Manual Time Entries
      summary: Create manual time entry
      description: |
        Create a new manual time entry for times not captured through
        attendance records. The associated timesheet must be editable.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - manual_time_entry
              properties:
                manual_time_entry:
                  type: object
                  required:
                  - timesheet_id
                  - date
                  - start_time
                  - end_time
                  properties:
                    timesheet_id:
                      type: integer
                      example: 123
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      description: Hours worked (calculated if not provided)
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Manual entry for missed punch
                    shift_id:
                      type: integer
                      description: Associated shift ID (optional). Must be a shift
                        the timesheet's owner is assigned to or punched against.
                      example: 456
      responses:
        '201':
          description: Manual time entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entry:
                    allOf:
                    - "$ref": "#/components/schemas/ManualTimeEntry"
                    - type: object
                      properties:
                        edit_history:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: end_time
                              original_value:
                                type: string
                                example: '16:30:00'
                              new_value:
                                type: string
                                example: '17:00:00'
                              reason:
                                type: string
                                example: Employee requested time correction
                              edited_by:
                                type: string
                                example: John Doe
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-16T09:00:00Z'
                        validation_warnings:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - overlap
                                - excessive_hours
                                - weekend_work
                                example: overlap
                              message:
                                type: string
                                example: This entry overlaps with another time entry
                                  on the same date
                        pay_calculation:
                          type: object
                          nullable: true
                          description: Null when the entry has no hours. Pay is at
                            the caller's own rate on file (hourly rate, or annual
                            salary / 2080); when no rate is on file the hours are
                            still given and every pay and rate field is null.
                          properties:
                            regular_hours:
                              type: number
                              format: float
                              example: 8.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 0.0
                            regular_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 200.0
                            overtime_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 0.0
                            total_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 200.0
                            hourly_rate:
                              type: number
                              format: float
                              nullable: true
                              example: 25.0
                            overtime_rate:
                              type: number
                              format: float
                              nullable: true
                              example: 37.5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: "`duplicate_entry` — an entry with exactly these punches (date,
            start_time, end_time) is already on the timesheet, typically a retried
            POST sent without an `Idempotency-Key`. Nothing was created; `details.existing_entry_id`
            names the entry that holds them."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation failed, timesheet not editable, or `shift_not_assigned`
            — `shift_id` names a shift the timesheet's owner holds no (non-declined)
            assignment on and never punched against. Omit `shift_id` to record the
            time without a shift.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/manual_time_entries/{id}":
    get:
      tags:
      - Manual Time Entries
      summary: Get manual time entry details
      description: |
        Get detailed information about a specific manual time entry including
        edit history, validation warnings, and pay calculations.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Manual time entry ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Manual time entry details
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entry:
                    allOf:
                    - "$ref": "#/components/schemas/ManualTimeEntry"
                    - type: object
                      properties:
                        edit_history:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: end_time
                              original_value:
                                type: string
                                example: '16:30:00'
                              new_value:
                                type: string
                                example: '17:00:00'
                              reason:
                                type: string
                                example: Employee requested time correction
                              edited_by:
                                type: string
                                example: John Doe
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-16T09:00:00Z'
                        validation_warnings:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - overlap
                                - excessive_hours
                                - weekend_work
                                example: overlap
                              message:
                                type: string
                                example: This entry overlaps with another time entry
                                  on the same date
                        pay_calculation:
                          type: object
                          nullable: true
                          description: Null when the entry has no hours. Pay is at
                            the caller's own rate on file (hourly rate, or annual
                            salary / 2080); when no rate is on file the hours are
                            still given and every pay and rate field is null.
                          properties:
                            regular_hours:
                              type: number
                              format: float
                              example: 8.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 0.0
                            regular_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 200.0
                            overtime_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 0.0
                            total_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 200.0
                            hourly_rate:
                              type: number
                              format: float
                              nullable: true
                              example: 25.0
                            overtime_rate:
                              type: number
                              format: float
                              nullable: true
                              example: 37.5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Manual time entry not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    put:
      tags:
      - Manual Time Entries
      summary: Update manual time entry
      description: |
        Update a manual time entry with new times or notes. The associated
        timesheet must be editable.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Manual time entry ID
        schema:
          type: integer
          example: 456
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - manual_time_entry
              properties:
                manual_time_entry:
                  type: object
                  properties:
                    date:
                      type: string
                      format: date
                      example: '2024-01-15'
                    start_time:
                      type: string
                      format: time
                      example: '09:00:00'
                    end_time:
                      type: string
                      format: time
                      example: '17:00:00'
                    hours:
                      type: number
                      format: float
                      example: 8.0
                    edit_notes:
                      type: string
                      example: Corrected end time
                    shift_id:
                      type: integer
                      example: 456
                edit_reason:
                  type: string
                  description: Reason for the edit (for audit trail)
                  example: Employee requested time correction
      responses:
        '200':
          description: Manual time entry updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual_entry:
                    allOf:
                    - "$ref": "#/components/schemas/ManualTimeEntry"
                    - type: object
                      properties:
                        edit_history:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              field_changed:
                                type: string
                                example: end_time
                              original_value:
                                type: string
                                example: '16:30:00'
                              new_value:
                                type: string
                                example: '17:00:00'
                              reason:
                                type: string
                                example: Employee requested time correction
                              edited_by:
                                type: string
                                example: John Doe
                              edited_at:
                                type: string
                                format: date-time
                                example: '2024-01-16T09:00:00Z'
                        validation_warnings:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - overlap
                                - excessive_hours
                                - weekend_work
                                example: overlap
                              message:
                                type: string
                                example: This entry overlaps with another time entry
                                  on the same date
                        pay_calculation:
                          type: object
                          nullable: true
                          description: Null when the entry has no hours. Pay is at
                            the caller's own rate on file (hourly rate, or annual
                            salary / 2080); when no rate is on file the hours are
                            still given and every pay and rate field is null.
                          properties:
                            regular_hours:
                              type: number
                              format: float
                              example: 8.0
                            overtime_hours:
                              type: number
                              format: float
                              example: 0.0
                            regular_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 200.0
                            overtime_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 0.0
                            total_pay:
                              type: number
                              format: float
                              nullable: true
                              example: 200.0
                            hourly_rate:
                              type: number
                              format: float
                              nullable: true
                              example: 25.0
                            overtime_rate:
                              type: number
                              format: float
                              nullable: true
                              example: 37.5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Update failed, timesheet not editable, or `shift_not_assigned`
            — a changed `shift_id` names a shift the timesheet's owner is not assigned
            to and never punched against.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Manual Time Entries
      summary: Delete manual time entry
      description: |
        Delete a manual time entry. Only entries not tied to attendance
        records can be deleted, and the timesheet must be editable.

        **Token scopes:** `write:timesheets` and `destructive:timekeeping` —
        deleting an entry is a delete-level act (#7246).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Manual time entry ID
        schema:
          type: integer
          example: 456
      responses:
        '204':
          description: Manual time entry deleted successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Cannot delete entry (tied to attendance or timesheet not editable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/service_desk":
    get:
      tags:
      - Service Desk
      summary: List support tickets
      description: "Retrieve all SERVICE DESK SUPPORT TICKETS and HELP REQUESTS for
        the current user (NOT forms or form templates). Returns both tickets created
        by the user (as requester) and tickets assigned to the user (as assignee).\n\nUse
        this endpoint to:\n- List all my support tickets\n- Show my help requests
        \ \n- View my IT tickets\n- Get support request list\n- See incident reports\n-
        Check service desk tickets\n- Find support tickets by status or priority\n\nSupports
        filtering by status and priority. ⚠️ This is for SUPPORT TICKETS only, not
        for forms.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        description: |
          Which set to list. `mine` (default) — your requests, and ones assigned to, created by or escalated to you, plus approvals you can give.
          `assigned`, `team`, `available` — your working set as an agent (Service Desk staff only; 403 otherwise).
          `approvals` — waiting for an approval you can give. `all` — the whole tenant, for the people the web ticket list admits.
        schema:
          type: string
          enum:
          - mine
          - assigned
          - team
          - available
          - approvals
          - all
      - name: assigned_to
        in: query
        required: false
        description: "`me` — only tickets assigned to you"
        schema:
          type: string
          enum:
          - me
      - name: requested_by
        in: query
        required: false
        description: "`me` — only tickets you requested"
        schema:
          type: string
          enum:
          - me
      - name: unassigned
        in: query
        required: false
        schema:
          type: boolean
      - name: escalation_active
        in: query
        required: false
        description: '`true` — escalated and not yet resolved, closed, cancelled or
          rejected: the set the dashboard "Escalated" tile counts'
        schema:
          type: boolean
      - name: sla_breached
        in: query
        required: false
        description: "`true` — SLA deadline passed on a ticket not yet resolved, closed,
          cancelled or rejected; `false` — the rest"
        schema:
          type: boolean
      - name: approver
        in: query
        required: false
        description: "`me` — only tickets waiting for an approval you can give"
        schema:
          type: string
          enum:
          - me
      - name: department_id
        in: query
        required: false
        description: Limit to one service desk (department)
        schema:
          type: integer
      - name: status
        in: query
        description: Filter by ticket status. Repeat it (`status[]=assigned&status[]=in_progress`)
          for several; `escalated` is the escalation flag, OR-ed with any statuses
          given.
        schema:
          oneOf:
          - type: string
            enum:
            - submitted
            - queued
            - assigned
            - in_progress
            - pending_approval
            - escalated
            - resolved
            - closed
            - cancelled
            - rejected
          - type: array
            items:
              type: string
              enum:
              - submitted
              - queued
              - assigned
              - in_progress
              - pending_approval
              - escalated
              - resolved
              - closed
              - cancelled
              - rejected
      - name: escalated
        in: query
        required: false
        description: "`true` — escalated tickets (any status); `false` — tickets that
          are not escalated"
        schema:
          type: boolean
      - name: priority
        in: query
        description: Filter by priority level. Repeat it (`priority[]=high&priority[]=critical`)
          for several. Ignored for a caller the tenant hides priority from.
        schema:
          oneOf:
          - type: string
            enum:
            - low
            - medium
            - high
            - critical
          - type: array
            items:
              type: string
              enum:
              - low
              - medium
              - high
              - critical
      - name: search
        in: query
        required: false
        description: |
          Staff sets only (`assigned`, `team`, `available`, `all`) — the web lists' own search.
          On `assigned` / `team` / `available`: the title. On `all`: title, description, or a ticket number (`1169` / `#1169`).
        schema:
          type: string
      - name: category
        in: query
        required: false
        description: Staff sets only — the request type slug (e.g. `it_support`).
          Repeat it for several. On `all`, the web list's `type` is accepted too and
          wins when both are sent.
        schema:
          oneOf:
          - type: string
          - type: array
            items:
              type: string
      - name: requester
        in: query
        required: false
        description: Staff sets only — the requester's user id. Never matches an anonymous
          ticket, whose requester is withheld (`requester.id` is null).
        schema:
          type: integer
      - name: asset_pro_asset_id
        in: query
        required: false
        description: On `all` only — the tickets linked to one Asset Pro asset (the
          asset page's list), never an anonymous one. An id naming no asset of this
          organization matches nothing.
        schema:
          type: integer
      - name: open_days
        in: query
        required: false
        description: Staff sets only — still open and submitted more than this many
          days ago
        schema:
          type: integer
      - name: due_soon
        in: query
        required: false
        description: |
          Staff sets only — on the ticket's due date (`due_at`): `24h` due within 24 hours, `7d` within 7 days,
          `custom` due on or before `due_before`. Any other value is ignored.
        schema:
          type: string
          enum:
          - 24h
          - 7d
          - custom
      - name: due_before
        in: query
        required: false
        description: With `due_soon=custom` — a date (YYYY-MM-DD); an unreadable date
          means today
        schema:
          type: string
          format: date
      - name: view
        in: query
        required: false
        description: |
          `scope=all` only — the web ticket list's saved views. `my_department` — tickets in the
          departments you manage; `awaiting_my_approval` — waiting for your approval (the set the
          dashboard widget counts; open to anyone with requests to approve, even without the rest
          of the full list); `past_sla` — the SLA deadline has passed; `unassigned` — no one assigned.
          Any other value is ignored.
        schema:
          type: string
          enum:
          - my_department
          - awaiting_my_approval
          - past_sla
          - unassigned
      - name: assigned
        in: query
        required: false
        description: |
          `scope=all` only — the web list's "Assigned to" filter: a person's user id (numeric) keeps the tickets assigned to
          them; `unassigned` — no one assigned;
          `waiting` — tickets with a requester's reply waiting on you.
        schema:
          oneOf:
          - type: integer
          - type: string
            enum:
            - unassigned
            - waiting
        example: 77
      - name: assignee_id
        in: query
        required: false
        description: "`scope=all` only — the same as a numeric `assigned` (the URL-only
          name, kept for links already written); it wins when both are sent."
        schema:
          type: integer
      - name: team_id
        in: query
        required: false
        description: "`scope=all` only — tickets routed to one support team."
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        description: Default 100, max 100
        schema:
          type: integer
          default: 100
      responses:
        '200':
          description: List of tickets retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  scope:
                    type: string
                    description: The set listed (an unknown `scope` lists `mine`)
                  counts:
                    type: object
                    description: Tallies over the whole filtered set, before paging
                    properties:
                      total:
                        type: integer
                      open:
                        type: integer
                      resolved:
                        type: integer
                      overdue:
                        type: integer
                      aging:
                        type: integer
                  count:
                    type: integer
                    example: 5
                    description: Total matching tickets (all pages)
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total_pages:
                    type: integer
                  has_more:
                    type: boolean
                  tickets:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SupportTicketSummary"
                  assignee_options:
                    type: array
                    description: |
                      `scope=all` only — the people the web list's "Assigned to" filter offers: whoever the tickets of
                      the current `view` and `department_id` are assigned to, by name (the other filters do not narrow it).
                      Send an `id` as `assigned` to list that person's tickets. A person picked with `assigned` who still
                      holds a ticket in the list but none in the view is included. Under "Hide Assignee from Requestor",
                      a caller who is not shown other people's assignees is offered only themself.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 77
                        name:
                          type: string
                          example: Priya Shah
                        active:
                          type: boolean
                          description: '`false` for someone no longer an active member
                            of this organization (shown "(left)" on the web)'
        '403':
          description: |
            `scope=all` for a caller without the full ticket list, or `scope=team`
            for someone who is not Service Desk staff — or the token lacks
            `read:service_desk`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: forbidden
                      message:
                        type: string
    post:
      tags:
      - Service Desk
      summary: Create support ticket
      description: |
        Create a new support ticket or help request. Use this to create ticket, submit request, report issue, request help, open ticket, file support request, log incident, create help request, or submit support ticket.

        Draw the form from `GET /service_desk/service_catalog`: each type's
        `form` block says which fields to show and require, and the top-level
        `form` block carries the form-wide rules (priority, anonymous, KB).

        **Intake rules are enforced for the fields you send.** Urgency reason,
        estimated cost / business justification and custom fields each have
        required rules (see the catalog). A rule applies once the request
        carries its field's key — so send the key (blank if unanswered) for every
        field your form draws, and a client that does not draw a field is not
        refused over it.

        **Idempotency.** Send an `Idempotency-Key` header per Submit. A retry
        with the same key and body replays the first 201 (same ticket id,
        `X-Idempotency-Cached: true`) instead of filing again; the same key with a
        different body answers 409 `idempotency_conflict`; a retry while the first
        is still running answers 409 `idempotency_in_progress` with `Retry-After`.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: One key per Submit; retries reuse it.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - ticket
              properties:
                ticket:
                  type: object
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      description: Brief title of the issue
                      example: Laptop overheating issue
                    description:
                      type: string
                      description: Detailed description of the problem. Required only
                        when the type's `form.description_required` is true.
                      example: My laptop has been overheating when running multiple
                        applications
                    request_type:
                      type: string
                      description: |
                        A `type` slug from `GET /service_desk/service_catalog`. Not a
                        fixed list — tenants define their own request types.
                      example: it_support
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      description: |
                        Send it only when the catalog's `form.priority.shown` is true.
                        When the tenant sets priority itself (priority levels off, or a
                        system / agent priority mode) the server replaces it.
                      example: medium
                    location_id:
                      type: integer
                      nullable: true
                      example: 1
                    service_department_id:
                      type: integer
                      nullable: true
                      description: Ignored unless it is a department of this tenant.
                    estimated_cost:
                      type: number
                      nullable: true
                      description: A layout field (`form.fields.estimated_cost`).
                        Also drives the cost half of the approval rule.
                    business_justification:
                      type: string
                      nullable: true
                      description: A layout field (`form.fields.business_justification`).
                    due_at:
                      type: string
                      format: date-time
                      nullable: true
                      description: A layout field (`form.fields.due_at`) — "Needed
                        by".
                    urgency_reason:
                      type: string
                      nullable: true
                      description: |
                        "Why is this urgent?" — required for the priorities in the
                        catalog's `form.priority.urgency_reason_required_for` when
                        this key is sent together with a `priority`.
                    anonymous:
                      type: boolean
                      description: |
                        File without a requester name. Ignored unless the catalog's
                        `form.anonymous_allowed` is true. The filer still sees the
                        ticket in their own list.
                    custom_fields:
                      type: object
                      additionalProperties: true
                      description: |
                        Answers keyed by each custom field's `field_name` (from the
                        type's `form.custom_fields`). A multiselect takes an array of
                        strings; a people / location / department field takes the
                        display name. Unknown keys, and internal-only fields for a
                        requester, are dropped.
                      example:
                        device_type: Laptop
                        business_impact: I cannot work at all
                    requester_id:
                      type: integer
                      description: File FOR this person. Only when `GET /config` says
                        `permissions.can_create_on_behalf` — otherwise the request
                        is refused with 403 and nothing is filed. A person who is
                        not in this organization is a 422. Sending the caller's own
                        id is the same as leaving it out.
                    vendor_id:
                      type: integer
                      description: Kept only for the request types in `GET /config`
                        → `vendor_request_types`.
                notify_user_ids:
                  type: array
                  description: People to loop in ("Notify others") — ignored on an
                    anonymous ticket or when the type's form hides the field; at most
                    10.
                  items:
                    type: integer
          multipart/form-data:
            schema:
              type: object
              description: The same `ticket[...]` fields and `notify_user_ids[]`,
                plus `attachments[]` files (10 MB each, up to the tenant's limit).
      responses:
        '201':
          description: Ticket created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Support ticket created successfully
                  attachments_warning:
                    type: string
                    nullable: true
                    description: |
                      Null when every file in `attachments[]` was stored. Otherwise a
                      sentence naming what was not — e.g. "2 of 3 files were attached.
                      big.png exceeds the 10 MB size limit." The files that passed ARE
                      attached; the ticket is filed either way.
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: |
            Two shapes. The caller may not file this request type, or sent a
            `requester_id` without `permissions.can_create_on_behalf`:
            `{success: false, error: {code: "forbidden", message: "<sentence>"}}` (shown below). Service Desk not
            enabled, or the token lacks `write:service_desk`: the standard
            `{error: {code, message}}` envelope (codes `service_desk_not_enabled`,
            `insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: forbidden
                      message:
                        type: string
                        example: You don't have access to submit that type of request.
        '409':
          description: Idempotency conflict (key reused with a different body, or
            the first request is still running)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error. Each entry of `errors` is a readable sentence.
            A `request_type` of `grievance` is also refused here (`error` only) —
            grievances are filed through POST /service_desk/grievances — and so is
            a `requester_id` that is not a person in this organization.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: validation_failed
                      message:
                        type: string
                        example: Failed to create ticket
                  errors:
                    type: array
                    items:
                      type: string
                    example:
                    - Device type is required
                    - Urgency reason can't be blank
  "/service_desk/search":
    get:
      tags:
      - Service Desk
      summary: Search support tickets
      description: |
        Search and filter SERVICE DESK SUPPORT TICKETS with advanced query capabilities (NOT for forms). Supports full-text search across ticket title, description, and resolution notes with pagination and sorting.

        Use this endpoint to:
        - Search support tickets by keyword
        - Find help requests
        - Filter tickets by status or priority
        - Find specific support issues
        - Search IT ticket history
        - Look for related support tickets
        - Find tickets by type (IT, HR, facilities, etc.)

        ⚠️ This searches SUPPORT TICKETS only, not forms or form submissions.
      security:
      - BearerAuth: []
      parameters:
      - name: query
        in: query
        description: Search term (searches title, description, resolution notes)
        schema:
          type: string
          example: laptop
      - name: status
        in: query
        description: Filter by status. Repeat it (`status[]=…`) for several; `escalated`
          is the escalation flag, OR-ed with any statuses given.
        schema:
          oneOf:
          - type: string
            enum:
            - submitted
            - queued
            - assigned
            - in_progress
            - pending_approval
            - escalated
            - resolved
            - closed
            - cancelled
            - rejected
          - type: array
            items:
              type: string
              enum:
              - submitted
              - queued
              - assigned
              - in_progress
              - pending_approval
              - escalated
              - resolved
              - closed
              - cancelled
              - rejected
      - name: priority
        in: query
        description: Filter by priority
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: request_type
        in: query
        description: Filter by request type
        schema:
          type: string
          example: it_support
      - name: created_after
        in: query
        description: Filter tickets created after this date
        schema:
          type: string
          format: date-time
      - name: created_before
        in: query
        description: Filter tickets created before this date
        schema:
          type: string
          format: date-time
      - name: sort_by
        in: query
        description: Field to sort by
        schema:
          type: string
          default: created_at
          enum:
          - created_at
          - updated_at
          - priority
          - status
      - name: sort_order
        in: query
        description: Sort direction
        schema:
          type: string
          default: desc
          enum:
          - asc
          - desc
      - name: page
        in: query
        description: Page number
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Results per page (max 100)
        schema:
          type: integer
          default: 20
          maximum: 100
      responses:
        '200':
          description: Search results with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total_pages:
                    type: integer
                  has_more:
                    type: boolean
                  tickets:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SupportTicketSummary"
  "/service_desk/service_catalog":
    get:
      tags:
      - Service Desk
      summary: Browse service catalog
      description: |
        Browse the service catalog to see available request types for submitting
        help requests. Returns all enabled service types organized by popularity.
        Supports search and category filtering.

        Also carries the **New Request form rules**, read from the same tenant
        settings the web form uses: `form` (form-wide) and, on each
        `service_types` entry, `form` (that type's fields, templates and custom
        fields), already resolved for this viewer.

        Accessible to all authenticated users (not admin-only).
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        description: Search term to filter service types by title, description, or
          examples
        schema:
          type: string
      - name: category
        in: query
        description: Filter by category name
        schema:
          type: string
      - name: department_id
        in: query
        description: Filter by service department ID
        schema:
          type: integer
      responses:
        '200':
          description: Service catalog retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  count:
                    type: integer
                  service_types:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceCatalogType"
                  popular_types:
                    type: array
                    description: The most-filed types, for a "popular" strip. Same
                      shape as `service_types` entries WITHOUT `form` — read the form
                      rules from the matching `service_types` entry.
                    items:
                      "$ref": "#/components/schemas/ServiceCatalogType"
                  categories:
                    type: array
                    items:
                      type: string
                  form:
                    "$ref": "#/components/schemas/ServiceDeskIntakeForm"
        '401':
          description: Authentication required
  "/service_desk/kb/search":
    get:
      tags:
      - Service Desk
      summary: Search the knowledge base
      description: |
        Search the service desk knowledge base (help articles, FAQs, documents)
        for self-service answers. Accessible to all authenticated members.

        Uses the same hybrid search the Ask AI service desk agent uses, with
        the same role-based visibility filtering — callers only see articles
        their role permits. Results are capped at 10 and never cached.

        With no `q`, browse instead: the articles the caller may read,
        newest first, 20 a page (`page`, `has_more`), optionally narrowed by
        `category`, or by `request_type` (the category that type's help usually
        lives in — falling back to all articles when that category is empty).

        Every page says whether the caller sees unpublished work
        (`can_see_unpublished`) — the web Knowledge Base's rule: a tenant or
        Service Desk admin sees every status, a KB approver the review queue,
        an author their own drafts and rejected work, everyone else published
        articles only. When true, `statuses` lists the status chips the web
        Knowledge Base's Status filter offers, the list (browse and search)
        includes the unpublished articles the caller may see, and `status`
        narrows it to one status. Each article result carries its `status`
        and `status_label`.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Search query. Leave out to browse.
        schema:
          type: string
          example: VPN password reset
      - name: page
        in: query
        required: false
        description: Browse only
        schema:
          type: integer
      - name: category
        in: query
        required: false
        description: Browse only
        schema:
          type: string
      - name: request_type
        in: query
        required: false
        description: Browse only
        schema:
          type: string
      - name: status
        in: query
        required: false
        description: One article status (browse and search). A Knowledge Base entry
          (FAQ, document, video) is live content and is kept under `published` only.
          Any other value is a 400.
        schema:
          type: string
          enum:
          - draft
          - pending_review
          - approved
          - published
          - rejected
          - archived
      - name: limit
        in: query
        description: Maximum results (default 10, max 10)
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 10
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  query:
                    type: string
                    nullable: true
                    description: Null when browsing (no `q`)
                  count:
                    type: integer
                    description: Results in this response
                  page:
                    type: integer
                    description: Browse only
                  has_more:
                    type: boolean
                    description: Browse only — another page exists
                  can_see_unpublished:
                    type: boolean
                    description: The caller sees unpublished work (the staff variant
                      — status chips, a status pill per card)
                  statuses:
                    type: array
                    description: Only when `can_see_unpublished` — the status chips
                      to offer, in order
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          example: pending_review
                        label:
                          type: string
                          example: Pending review
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/KbSearchResult"
        '400':
          description: A non-scalar `q` (e.g. `q[]=x`), or a `status` that is not
            an article status
        '404':
          description: The Knowledge Base is turned off for this Service Desk (`GET
            /service_desk/config` → `features.knowledge_base` is false). The article
            and rating endpoints answer the same.
  "/service_desk/{id}":
    get:
      tags:
      - Service Desk
      summary: Get support ticket details
      description: "Retrieve detailed information about a specific SERVICE DESK SUPPORT
        TICKET or HELP REQUEST (NOT a form). This endpoint returns support ticket
        status, assigned agent, priority level, complete description, comment history,
        and resolution timeline.\n\nUse this endpoint to:\n- Check support ticket
        status (\"What's the status of ticket #70?\")\n- View help request details
        (\"Show me ticket 70\")\n- See who is handling a support ticket\n- Get IT
        support ticket information  \n- View incident report status\n- Check service
        desk request progress\n- Find out about a specific support request or help
        ticket\n\n⚠️ IMPORTANT: This is for SUPPORT TICKETS only, not for forms, templates,
        or form submissions. Ticket IDs are from the Service Desk system, not the
        Forms app.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID number
        schema:
          type: integer
      responses:
        '200':
          description: Ticket details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '404':
          description: Ticket not found or access denied
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    patch:
      tags:
      - Service Desk
      summary: Update support ticket
      description: |
        Update the editable fields of an existing support ticket.

        Field access is role-based:
        - Requesters may update **title** and **description** on their own ticket,
          only while the ticket is still editable (status submitted, queued, or assigned).
        - Privileged callers (business admins, super admins, help desk agents,
          or the current assignee) may additionally update **priority**,
          **request_type**, and **service_department_id**.

        Additional rules:
        - No one can edit resolved, closed, cancelled, or rejected tickets.
        - Priority changes also respect the business priority source mode
          (in "system"/"agent" modes only service desk staff may set priority)
          and the prevent-self-service-on-own-tickets setting.
        - **Status cannot be changed here** — use PATCH /service_desk/{id}/status.
        - Changing request_type may move the ticket to pending_approval if the
          new type requires approval.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - ticket
              properties:
                ticket:
                  type: object
                  properties:
                    title:
                      type: string
                      example: Laptop overheating issue (updated)
                    description:
                      type: string
                      example: Updated description with more details
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      description: Privileged callers only; also gated by priority
                        source mode
                    request_type:
                      type: string
                      description: Privileged callers only
                      example: it_support
                    service_department_id:
                      type: integer
                      nullable: true
                      description: Privileged callers only
      responses:
        '200':
          description: Ticket updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: Not permitted to edit this ticket (or this field)
        '404':
          description: Ticket not found or access denied
        '422':
          description: Validation error
  "/service_desk/{id}/assign":
    post:
      tags:
      - Service Desk
      summary: Assign or transfer ticket
      description: |
        Assign a support ticket to a user, transfer it to another user, or move
        it into a team queue. Privileged only: business admins, super admins,
        managers, help desk agents, or the ticket's current assignee.

        Rules:
        - Closed, cancelled, or rejected tickets cannot be assigned/transferred.
        - Tickets pending approval must be approved or rejected first.
        - When prevent-self-service is enabled, the requester cannot assign
          their own ticket to themselves.
        - Fires the same notifications and milestone tracking as the web app.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_type:
                  type: string
                  enum:
                  - user
                  - team
                  default: user
                  description: Assign to an individual user or transfer to a team
                    queue
                assignee_id:
                  type: integer
                  description: Required when target_type is "user"
                  example: 42
                support_team_id:
                  type: integer
                  description: Required when target_type is "team"
                  example: 3
                transfer_reason:
                  type: string
                  description: Optional reason recorded on transfer
                  example: Better suited for the networking team
                notify_assignee:
                  type: boolean
                  default: true
                  description: Send the assignment/transfer notification (default
                    true)
      responses:
        '200':
          description: Ticket assigned or transferred successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: Not authorized to assign or transfer this ticket
        '404':
          description: Ticket, assignee, or team not found
        '422':
          description: Ticket state does not allow assignment, or missing target
  "/service_desk/{id}/claim":
    post:
      tags:
      - Service Desk
      summary: Claim an unowned ticket
      description: |
        Take a submitted or queued ticket nobody holds yet — the web ticket
        page's Claim Ticket. Offered when the ticket's `permissions.can_claim`
        is true.

        Rules:
        - Service Desk staff only (an agent, an admin, or the Service Desk app admin).
        - A queued ticket is claimed only from a team queue the caller is on;
          business admins and super admins may claim from any queue.
        - When prevent-self-service is enabled, the requester cannot claim
          their own ticket.
        - Fires the same assignment notice, history and timeline line as the web app.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Claimed — `success` true, `message`, and the updated `ticket`
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: Not Service Desk staff, the caller's own ticket under prevent-self-service,
            or a team queue the caller is not on
        '404':
          description: Ticket not found or not visible
        '422':
          description: Already claimed, pending approval, or no longer unowned; `error`
            says why
  "/service_desk/{id}/comments":
    get:
      tags:
      - Service Desk
      summary: Get ticket comments
      description: |
        Retrieve the comments and updates on a support ticket, oldest first, a page at a time. Use this to view ticket comments, see conversation history, check what was discussed, read agent responses, view ticket updates, or see comment thread.

        Paged at 100 comments by default (and at most). A thread of up to 100 comments comes back whole; a longer one returns `has_more: true` — request the next `page` until it is false. `comments_count` is always the total number of visible comments.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        description: Default 100, max 100
        schema:
          type: integer
          default: 100
      responses:
        '200':
          description: One page of the ticket's comments
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  ticket_id:
                    type: integer
                  comments_count:
                    type: integer
                    description: Total visible comments on the ticket (all pages)
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TicketComment"
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total_pages:
                    type: integer
                  has_more:
                    type: boolean
                    description: Another page of comments exists
        '404':
          description: Ticket not found or access denied
    post:
      tags:
      - Service Desk
      summary: Add comment to ticket
      description: |
        Add a comment or update to an existing support ticket. Use this to add comment, update ticket, provide more information, respond to agent, add details, reply to ticket, post update, or add note to ticket.

        Send an `Idempotency-Key` per send: a retry with the same key replays the
        first 201 instead of posting (and notifying) twice.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: One key per send; retries reuse it.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: object
                  required:
                  - content
                  properties:
                    content:
                      type: string
                      description: Comment text
                      example: I've restarted the laptop and the issue persists
                    internal:
                      type: boolean
                      description: |
                        Internal note (not visible to requester). Only Service Desk staff,
                        app admins and the ticket's assignee may post one; anyone else who
                        sends `internal: true` is refused with 403 rather than having the
                        note published to the requester.
                      default: false
                saved_reply_id:
                  type: integer
                  description: Staff only — the saved reply this comment was written
                    from, to count its use.
          multipart/form-data:
            schema:
              type: object
              description: The same fields, plus files. Mentions go in `comment[content]`
                as `@[Name](mention:<user id>)`, as on the web.
              properties:
                comment[content]:
                  type: string
                comment[internal]:
                  type: boolean
                saved_reply_id:
                  type: integer
                attachments[]:
                  type: array
                  description: Up to the tenant's per-request attachment limit; 10
                    MB each.
                  items:
                    type: string
                    format: binary
      responses:
        '201':
          description: Comment added successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  comment:
                    "$ref": "#/components/schemas/TicketComment"
                  attachments_warning:
                    type: string
                    nullable: true
                    description: Set when some files were not attached (over the limit,
                      refused type, or attachments switched off). The comment itself
                      was posted.
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '403':
          description: The caller asked for an internal note but may not post one
            on this ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: forbidden
                      message:
                        type: string
        '404':
          description: Ticket not found
        '422':
          description: Validation error
  "/service_desk/{id}/status":
    patch:
      tags:
      - Service Desk
      summary: Update ticket status
      description: |
        Change the status of a support ticket (resolve, close, reopen, mark in progress). Use this to close ticket, resolve ticket, reopen ticket, mark in progress, update status, change ticket state, mark resolved, mark closed, or start work on ticket.

        Also approve / reject (with `approval_notes` / `rejection_reason`) and
        cancel / reinstate (with `cancellation_reason` / `reinstate_reason`).
        `permissions.allowed_status_actions` on the ticket lists the actions
        this caller may take right now.

        Closing, cancelling or rejecting a ticket (`action_type` `close`, `cancel` or
        `reject`) is delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Ticket ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - action_type
              properties:
                action_type:
                  type: string
                  description: Status action to perform
                  enum:
                  - resolve
                  - close
                  - reopen
                  - in_progress
                  - start_progress
                  - revert_to_assigned
                  - approve
                  - reject
                  - cancel
                  - reinstate
                  example: resolve
                notes:
                  type: string
                  description: Optional notes about status change (resolution notes,
                    closing reason, etc.)
                  example: Issue was fixed by restarting the VPN service
      responses:
        '200':
          description: Status updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  ticket:
                    "$ref": "#/components/schemas/SupportTicketDetail"
        '400':
          description: Invalid action type
        '403':
          description: Not authorized to perform this action
        '404':
          description: Ticket not found
        '422':
          description: |
            Status update failed. `reopen` on a grievance whose workflow is
            active is refused with "A grievance isn't reopened — an administrator
            moves it back to an earlier stage, with a reason."
  "/service_desk/{id}/comments/{comment_id}":
    patch:
      tags:
      - Service Desk
      summary: Edit a comment
      description: The author within 15 minutes of posting, or an admin. Not on a
        rejected or cancelled ticket. `internal` may change only for someone who may
        write internal notes.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: comment_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: object
                  properties:
                    content:
                      type: string
                    internal:
                      type: boolean
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  comment:
                    "$ref": "#/components/schemas/TicketComment"
        '403':
          description: Not the author inside the window, and not an admin
        '404':
          description: Ticket or comment not found for this caller
        '422':
          description: The ticket is read-only (rejected or cancelled), or validation
            failed
    delete:
      tags:
      - Service Desk
      summary: Delete a comment
      description: |
        The author within 5 minutes of posting, or an admin. Not on a rejected or cancelled ticket.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: comment_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  deleted_comment_id:
                    type: integer
        '403':
          description: Not the author inside the window, and not an admin
        '404':
          description: Ticket or comment not found for this caller
        '422':
          description: The ticket is read-only (rejected or cancelled)
  "/service_desk/{id}/activity":
    get:
      tags:
      - Service Desk
      summary: Ticket activity (comments and history)
      description: |
        The ticket's timeline as the web page draws it: top-level comments and
        lifecycle milestones (submitted, assigned, priority changed, resolved, …)
        merged in time order and filtered for the caller — internal notes only
        for staff and the assignee, staff-only history only for staff, and an
        agent's name left out of a milestone when the tenant hides the assignee.
        Returns the newest `limit` entries, oldest first.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: limit
        in: query
        required: false
        description: How many of the newest entries to return (default 50, max 200).
        schema:
          type: integer
      responses:
        '200':
          description: Activity
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  ticket_id:
                    type: integer
                  truncated:
                    type: boolean
                    description: Older entries exist beyond `limit`.
                  activity:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TicketActivityEntry"
        '404':
          description: Ticket not found or access denied
  "/service_desk/saved_replies":
    get:
      tags:
      - Service Desk
      summary: Saved replies (staff)
      description: The composer's canned answers, most used first — Service Desk staff
        only. Placeholders `{{requester_name}}`, `{{ticket_id}}`, `{{agent_name}}`
        are left for the app to fill.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Saved replies
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  saved_replies:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        body:
                          type: string
                        category:
                          type: string
                          nullable: true
                        usage_count:
                          type: integer
        '403':
          description: Not Service Desk staff
  "/service_desk/{id}/followers":
    post:
      tags:
      - Service Desk
      summary: Add a person to the ticket
      description: The support side or the requester, while the ticket is live; the
        person is notified. Returns `followers`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                  description: Who to add
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/followers/{user_id}":
    delete:
      tags:
      - Service Desk
      summary: Remove a person from the ticket
      description: Staff remove anyone; anyone may remove themselves.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: user_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/rating":
    post:
      tags:
      - Service Desk
      summary: Rate the service (requester)
      description: Once, after the ticket is resolved or closed. 1-5; `feedback` is
        required for 3 or below.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                rating:
                  type: integer
                  description: 1 to 5
                feedback:
                  type: string
                  description: Required for 3 or below
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/confirm_resolution":
    post:
      tags:
      - Service Desk
      summary: Confirm a suggested answer solved it
      description: The requester (or an admin) confirms the comment the Service Desk
        autopilot posted, which resolves the ticket. Not the same as close.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                comment_id:
                  type: integer
                  description: The pending confirmation comment
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/escalate":
    post:
      tags:
      - Service Desk
      summary: Escalate
      description: Staff or the assignee, on a ticket that can be escalated. Without
        escalate_to_id the tenant escalation target is used.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why
                escalate_to_id:
                  type: integer
                  description: Optional person
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/de_escalate":
    post:
      tags:
      - Service Desk
      summary: Remove an escalation
      description: An admin, the assignee, or the person it was escalated to.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/merge_targets":
    get:
      tags:
      - Service Desk
      summary: Tickets this one could merge into (staff)
      description: 'Open tickets matching q (a number, #1234, or title words). Grievances
        only merge with grievances.'
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: q
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/merge":
    post:
      tags:
      - Service Desk
      summary: Merge into another ticket (staff)
      description: |
        The merge service re-checks every rule and returns the refusal to show. This ticket is cancelled as merged.

        Cancelling this ticket makes the merge delete-level (owner decision
        2026-10-04, #6721): on top of `write:service_desk` it requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                target_id:
                  type: integer
                  description: The ticket to merge into
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/clone":
    get:
      tags:
      - Service Desk
      summary: Prefilled fields for a copy (staff)
      description: 'Nothing is saved: POST the returned `ticket` fields to /service_desk
        to file the copy.'
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/time_entries":
    post:
      tags:
      - Service Desk
      summary: Log time (staff)
      description: 1-1440 minutes; worked_on defaults to today when omitted, cannot
        be in the future, and must be YYYY-MM-DD (any other value is a 422, never
        today). Send an `Idempotency-Key` so a retry does not log the time twice.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                minutes:
                  type: integer
                  description: Minutes worked
                worked_on:
                  type: string
                  description: Date, YYYY-MM-DD
                note:
                  type: string
                  description: Up to 255 characters
      responses:
        '201':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/time_entries/{entry_id}":
    delete:
      tags:
      - Service Desk
      summary: Remove a time entry
      description: |
        Its author, or an admin.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: entry_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/parts":
    post:
      tags:
      - Service Desk
      summary: Request parts from inventory (staff)
      description: Needs the Procurement app on and open to the caller. Returns the
        updated `parts`. Send an `Idempotency-Key` so a retry does not request the
        part twice.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                inventory_item_id:
                  type: integer
                  description: Item
                quantity:
                  type: integer
                  description: At least 1
                cost_center:
                  type: string
                  description: Optional
                delivery_location:
                  type: string
                  description: Optional
                station_id:
                  type: integer
                  description: Optional
                notes:
                  type: string
                  description: Optional
                collect_at_location_id:
                  type: integer
                  description: Optional. Where the parts wait to be collected — one
                    of `parts.collect_points` from the ticket. Any other id is refused.
                requested_for_id:
                  type: integer
                  description: Optional. Who the parts are for, and so who is alerted
                    when they are ready — one of `parts.recipients` from the ticket
                    (the caller, or Service Desk staff on the job). Defaults to the
                    caller; anyone else is refused.
      responses:
        '200':
          description: 'Done — `success` true with the updated `parts`: `items` (issued
            and returned movements) and `requests` (open and declined requests, each
            with `requested_for`, `collect_at`, `picking_by`, `can_cancel` and per-line
            `quantity`, `issued`, `declined`, `remaining`, `estimated_cost`), and
            the choices this endpoint accepts: `collect_points` ([{id, name}]; empty
            when the tenant has none), `last_collect_point_id` and `recipients` ([{id,
            name, me}])'
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/parts/search":
    get:
      tags:
      - Service Desk
      summary: Find a part to request (staff)
      description: Active inventory items whose name or SKU contains `q`, or that
        a scanned barcode, case code, QR label or exact SKU in `q` names (that match
        comes first, with `matched_code`). Out-of-stock parts are included. Each part
        carries its unit cost, total on hand, and `places` — on hand per top-level
        stockroom, most first. Needs the Procurement app on and open to the caller.
        Fewer than 2 characters returns an empty list.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: q
        in: query
        required: true
        schema:
          type: string
        description: Name, SKU or scanned code
      responses:
        '200':
          description: "`success` true and `parts` — up to 20 of `{ id, name, sku,
            unit_cost, on_hand, places: [{ id, name, on_hand }], matched_code }`"
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Procurement is not enabled; `error` says so
  "/service_desk/{id}/parts/sources":
    get:
      tags:
      - Service Desk
      summary: One part's cost and where it is held (staff)
      description: The unit cost and on hand per top-level stockroom of an active
        inventory item, before requesting it.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: inventory_item_id
        in: query
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: "`success` true and `part` — `{ id, name, sku, unit_cost, on_hand,
            places: [{ id, name, on_hand }] }`"
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket, or an active part with that id, not found
        '422':
          description: Procurement is not enabled; `error` says so
  "/service_desk/{id}/parts/requests/{requisition_id}":
    delete:
      tags:
      - Service Desk
      summary: Withdraw a parts request
      description: |
        Only before the warehouse starts picking.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: requisition_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/parts/requests/{requisition_id}/dismiss":
    post:
      tags:
      - Service Desk
      summary: Dismiss a declined parts request
      description: Clears it off the ticket.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: requisition_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/parts/{transaction_id}":
    delete:
      tags:
      - Service Desk
      summary: Return an issued part
      description: Writes a returned movement.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: transaction_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Done — `success` true, usually with the updated `ticket`
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Refused in this state; `error` says why
  "/service_desk/{id}/project":
    patch:
      tags:
      - Service Desk
      summary: Set the project this ticket bills to (staff)
      description: The web Parts card's "Bills to". Every part requested afterwards
        inherits the project; parts already issued keep the one they were charged
        to. Send a blank `procurement_budget_id` to clear it. Needs the Procurement
        app on and open to the caller, like the other parts endpoints. Returns the
        updated `parts`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                procurement_budget_id:
                  type: integer
                  nullable: true
                  description: One of `parts.billable_projects` from the ticket; blank
                    or null clears the project. Any other id is refused.
      responses:
        '200':
          description: Done — `success` true, `message`, and the updated `parts` (with
            `project`, `billable_projects` and `suggested_project`)
        '403':
          description: Not permitted for this caller
        '404':
          description: Ticket not found or not visible
        '422':
          description: Procurement is not enabled, or the project is not one this
            ticket may bill to; `error` says why
  "/service_desk/support_teams":
    get:
      tags:
      - Service Desk
      summary: Active support teams
      description: For the assign / transfer picker. Assigning to a team only accepts
        an active one.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Teams
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  support_teams:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        tier_level:
                          type: integer
  "/service_desk/agents":
    get:
      tags:
      - Service Desk
      summary: Agents a ticket can be assigned to
      description: |
        The person half of the assign / transfer picker (the team half is GET /service_desk/support_teams) —
        the same people the web ticket page offers: active members of an active support team, or people granted
        agent rights in Settings → Agents. Admins and managers are not listed; they take or claim tickets themselves.
        Send one as `assignee_id` to POST /service_desk/{id}/assign. Service Desk staff only.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Agents, by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  agents:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        avatar_url:
                          type: string
                          nullable: true
        '403':
          description: The caller is not Service Desk staff, or the token lacks `read:service_desk`
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: forbidden
                      message:
                        type: string
  "/service_desk/dashboard":
    get:
      tags:
      - Service Desk
      summary: Agent dashboard (staff)
      description: 'The agent home screen: workload, tiles and short lists. Each tile
        rule is spelled out in `tile_rules`.'
      security:
      - BearerAuth: []
      parameters:
      - name: department_id
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Dashboard
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  department:
                    type: object
                    nullable: true
                    description: The desk `department_id` narrowed to, else null
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  workload:
                    type: object
                    properties:
                      total_assigned:
                        type: integer
                      open:
                        type: integer
                      overdue:
                        type: integer
                        description: Open, and the earlier of due date and SLA deadline
                          has passed
                      due_today:
                        type: integer
                        description: Open, due (earlier of due date and SLA deadline)
                          from now until midnight
                      due_tomorrow:
                        type: integer
                      capacity:
                        type: integer
                      utilization:
                        type: integer
                        description: Percent of capacity in use, capped at 100
                  tiles:
                    type: object
                    properties:
                      my_open:
                        type: integer
                      breached:
                        type: integer
                      escalated:
                        type: integer
                      team_queue:
                        type: integer
                        description: Routed to your teams, nobody has picked it up
                          yet
                      available:
                        type: integer
                      unclaimed:
                        type: integer
                        description: team_queue + available
                      pending_my_approval:
                        type: integer
                      ai_drafts:
                        type: integer
                  tile_rules:
                    type: object
                    additionalProperties:
                      type: string
                  sections:
                    type: object
                    properties:
                      my_tickets:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SupportTicketSummary"
                      team_queue:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SupportTicketSummary"
                      available:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SupportTicketSummary"
        '403':
          description: Not Service Desk staff
  "/service_desk/ai_drafts":
    get:
      tags:
      - Service Desk
      summary: AI-drafted replies waiting to be sent (staff)
      description: Drafts on a grievance appear only for people who may open that
        grievance.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Drafts
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  stats:
                    type: object
                    properties:
                      pending:
                        type: integer
                      sent_today:
                        type: integer
                      rejected_today:
                        type: integer
                  page:
                    type: integer
                  total_pages:
                    type: integer
                  drafts:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        status:
                          type: string
                        content:
                          type: string
                        final_content:
                          type: string
                          description: 'What Send delivers: the agent''s edit when
                            there is one (kept when a failed send returned the draft
                            to pending), else content.'
                        last_send_error:
                          type: string
                          nullable: true
                          description: Why the last Approve & Send failed, when a
                            failed send returned this draft to pending.
                        confidence:
                          type: number
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        ticket:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            title:
                              type: string
                            status:
                              type: string
        '403':
          description: Not Service Desk staff
  "/service_desk/ai_drafts/{draft_id}/send":
    post:
      tags:
      - Service Desk
      summary: Approve and send an AI draft (staff)
      security:
      - BearerAuth: []
      parameters:
      - name: draft_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                edited_content:
                  type: string
                  description: Send this text instead of the draft as written
                channel:
                  type: string
                  description: Defaults to email
      responses:
        '200':
          description: Sent
        '404':
          description: Not found, already processed, or on a ticket you may not open
        '422':
          description: Could not be sent; `error` says why
  "/service_desk/ai_drafts/{draft_id}":
    delete:
      tags:
      - Service Desk
      summary: Discard an AI draft (staff)
      security:
      - BearerAuth: []
      parameters:
      - name: draft_id
        in: path
        required: true
        schema:
          type: integer
      - name: reason
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: Discarded
        '404':
          description: Not found, already processed, or on a ticket you may not open
  "/service_desk/kb/articles/{article_id}":
    get:
      tags:
      - Service Desk
      summary: Read a knowledge base article
      description: The article as the web page shows it — the reader's KB-role rules,
        published only, the body through the page's safe-HTML allow-list. Counts a
        view. Send `from=ai_chat` when opened from the AI assistant.
      security:
      - BearerAuth: []
      parameters:
      - name: article_id
        in: path
        required: true
        schema:
          type: integer
      - name: from
        in: query
        required: false
        schema:
          type: string
          enum:
          - ai_chat
      responses:
        '200':
          description: Article
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  article:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      description:
                        type: string
                        nullable: true
                      category:
                        type: string
                        nullable: true
                      content_html:
                        type: string
                        description: Sanitised HTML
                      updated_at:
                        type: string
                        format: date-time
                      status:
                        type: string
                        description: Workflow status, an open set of strings; always
                          published here
                      status_label:
                        type: string
                        description: The status as a person reads it ("Published")
                      view_count:
                        type: integer
                      ratings:
                        type: object
                        description: '"Was this helpful?" votes; the web''s Helpfulness
                          is helpful / (helpful + not_helpful).'
                        properties:
                          helpful:
                            type: integer
                          not_helpful:
                            type: integer
                      my_rating:
                        type: string
                        nullable: true
                        enum:
                        - helpful
                        - not_helpful
                      can_rate:
                        type: boolean
                        description: May vote "Was this helpful?" (POST /api/v1/service_desk/kb/articles/{article_id}/rating)
                          — false for a token without write:service_desk
        '404':
          description: Not found, not published, or not for this reader
  "/service_desk/kb/articles/{article_id}/rating":
    post:
      tags:
      - Service Desk
      summary: Was this article helpful?
      description: One vote per person, which they may switch.
      security:
      - BearerAuth: []
      parameters:
      - name: article_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - helpful
              properties:
                helpful:
                  type: boolean
      responses:
        '200':
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  outcome:
                    type: string
                    enum:
                    - new_vote
                    - switched
                    - duplicate
                  message:
                    type: string
                  my_rating:
                    type: string
                    enum:
                    - helpful
                    - not_helpful
        '404':
          description: Not found for this reader
        '422':
          description: "`helpful` missing or not true/false — no vote is recorded"
  "/service_desk/config":
    get:
      tags:
      - Service Desk
      summary: Service Desk settings for the app
      description: |
        Everything the app needs to draw Service Desk screens: priorities,
        statuses (with `is_open` / `is_terminal`), departments, active support
        teams, locations, vendors and the request types that ask for one, saved
        replies (staff), attachment limits, rating rules, feature and module
        switches, the caller's permissions, and the form-wide New Request rules.
        The per-request-type form rules stay on GET /service_catalog.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  priorities:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                        label:
                          type: string
                        color:
                          type: string
                  statuses:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                        label:
                          type: string
                        color:
                          type: string
                        is_open:
                          type: boolean
                        is_terminal:
                          type: boolean
                  departments:
                    type: array
                    items:
                      type: object
                  support_teams:
                    type: array
                    items:
                      type: object
                  locations:
                    type: array
                    items:
                      type: object
                  vendors:
                    type: array
                    items:
                      type: object
                  vendor_request_types:
                    type: array
                    items:
                      type: string
                  saved_replies:
                    type: array
                    items:
                      type: object
                  attachments:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      max_per_request:
                        type: integer
                      max_file_bytes:
                        type: integer
                  rating:
                    type: object
                    properties:
                      min:
                        type: integer
                      max:
                        type: integer
                      feedback_required_at_or_below:
                        type: integer
                      survey_enabled:
                        type: boolean
                  features:
                    type: object
                  permissions:
                    type: object
                    properties:
                      staff:
                        type: boolean
                      can_create_on_behalf:
                        type: boolean
                      can_view_all_tickets:
                        type: boolean
                  new_request:
                    type: object
                    description: ServiceDesk::IntakeForm#form — priority, anonymous,
                      approval threshold, KB suggestions
  "/service_desk/my_tickets":
    get:
      tags:
      - Service Desk
      summary: Get my tickets widget (role-based tabs)
      description: |
        Dashboard widget endpoint. Returns service desk tickets organized into
        role-based tabs. Each tab includes a total_count (for badge display) and
        a small preview of recent tickets.

        Only tabs applicable to the user's role are returned:
        - created_by_me: always included (all users) — sorted by latest created first
        - assigned_to_me: help desk agents, managers, admins, super admins — sorted by escalated first, then priority, then oldest first
        - pending_approval_for_me: managers, admins, super admins — sorted by priority (oldest first)

        This is a preview endpoint — no pagination. Use the main ticket list
        for paginated browsing (triggered via "View All").

        Each tab's `label` is display text in the language the client asks for:
        the `locale` query parameter, else `Accept-Language` (first supported
        tag by q-value), else the caller's own profile language, else the
        tenant's default, else English — whether or not the tenant translates
        its web pages. The response carries `Vary: Accept-Language`. Branch on
        `key`, never on `label`.
      security:
      - BearerAuth: []
      parameters:
      - name: locale
        in: query
        required: false
        description: The language the client's own interface is in (e.g. `fr`). Takes
          precedence over `Accept-Language` for `label`. An unsupported code is ignored.
        schema:
          type: string
        example: fr
      - name: per_tab
        in: query
        description: Number of preview tickets per tab (default 5, max 10)
        schema:
          type: integer
          default: 5
          minimum: 1
          maximum: 10
      responses:
        '200':
          description: Widget data retrieved successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MyTicketsWidgetResponse"
        '401':
          description: Unauthorized
  "/service_desk/problems":
    get:
      tags:
      - Service Desk Problems
      summary: List problem records
      description: |
        The Problems list with the web's filters and search, newest first, plus the
        stats cards (total, open, known errors, average linked incidents).
        `view=known_errors` lists published Known Errors instead, searched on title,
        workaround and known-error description.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: One status, or several as status[]
        schema:
          type: string
          enum:
          - identified
          - investigating
          - root_cause_found
          - fix_in_progress
          - resolved
          - closed
      - name: priority
        in: query
        required: false
        description: One priority, or several as priority[]
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: category
        in: query
        required: false
        schema:
          type: string
          enum:
          - network
          - hardware
          - software
          - security
          - process
          - other
      - name: assignee_id
        in: query
        required: false
        schema:
          type: integer
      - name: known_error
        in: query
        required: false
        description: Only problems published as Known Errors
        schema:
          type: boolean
      - name: search
        in: query
        required: false
        description: Matches title or description
        schema:
          type: string
      - name: view
        in: query
        required: false
        schema:
          type: string
          enum:
          - known_errors
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: A page of problems
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  stats:
                    type: object
                    properties:
                      total:
                        type: integer
                      open:
                        type: integer
                      known_errors:
                        type: integer
                      avg_linked:
                        type: number
                  total_count:
                    type: integer
                  total_pages:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
                  problems:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceDeskProblemSummary"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
    post:
      tags:
      - Service Desk Problems
      summary: Create a problem record
      description: |
        Starts in `identified`, numbered PRB-YYYY-NNNN, with the caller as
        "identified by". Notifies the assignee when one is set. Fields may be sent
        at the top level or nested under `problem`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ServiceDeskProblemInput"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/form_options":
    get:
      tags:
      - Service Desk Problems
      summary: Options for the problem forms
      description: |
        Statuses, priorities, categories, root-cause methods, investigation entry
        types, incident link types, the assignee picker (first 100 active people by
        name, plus the problem's own assignee when `problem_id` is given) and
        service departments.
      security:
      - BearerAuth: []
      parameters:
      - name: problem_id
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Form options
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  statuses:
                    type: array
                    items:
                      type: string
                  priorities:
                    type: array
                    items:
                      type: string
                  categories:
                    type: array
                    items:
                      type: string
                  root_cause_methods:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                        label:
                          type: string
                  entry_types:
                    type: array
                    items:
                      type: string
                  link_types:
                    type: array
                    items:
                      type: string
                  assignees:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceDeskProblemUser"
                  service_departments:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/search_incidents":
    get:
      tags:
      - Service Desk Problems
      summary: Search tickets to link as incidents
      description: |
        Up to 20 tickets, newest first, matched on id or title, excluding those
        already linked to `problem_id`. Grievances are left out unless the caller
        may handle grievances.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
      - name: problem_id
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Matching tickets
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  incidents:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceDeskProblemIncident"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Service Desk Problems
      summary: Show a problem record
      description: |
        The record with its linked incidents (newest 50, grievance-clamped — a
        grievance is never listed or counted for someone who could not open it),
        configuration items, investigation entries and activity timeline (newest
        200 each, oldest first), the fix change request (while Change Management
        is on) and `permissions` — which controls work for this caller now.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The problem
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
    patch:
      tags:
      - Service Desk Problems
      summary: Edit a problem record
      description: Notifies a newly set assignee. Refused (422) once the problem is
        closed.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ServiceDeskProblemInput"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/transition":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Move a problem to its next lifecycle step
      description: |
        The lifecycle is strictly identified → investigating → root_cause_found →
        fix_in_progress → resolved → closed; any other move is refused with 422
        ("Invalid transition from x to y"). `record_root_cause` needs `root_cause`,
        `resolve` needs `resolution`, `close` needs `closure_notes`. Recording the
        root cause, resolving and closing notify the people involved.
        `permissions.available_events` on the show response names the legal step.

        Closing a problem (the `close` event, or `to_status` `closed`) is delete-level
        (owner decision 2026-10-04, #6721): requires the `destructive:service_desk`
        scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                  enum:
                  - start_investigation
                  - record_root_cause
                  - start_fix
                  - resolve
                  - close
                to_status:
                  type: string
                  description: The target status
                  in place of `event`:
                root_cause:
                  type: string
                root_cause_method:
                  type: string
                  enum:
                  - 5_whys
                  - fishbone
                  - fault_tree
                  - pareto
                  - timeline
                  - other
                resolution:
                  type: string
                closure_notes:
                  type: string
      responses:
        '200':
          description: Moved
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/close_as_duplicate":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Close a problem as a duplicate of another
      description: |
        From any status short of closed, the problem is closed in favour of
        `duplicate_of_id` — another problem of this organization that is not closed
        (list them with GET /service_desk/problems and a `status` filter). Its linked
        incidents move to that problem (one it already holds is dropped here), a
        published Known Error is retired, both timelines record it, and both
        assignees are told. `closure_notes` is optional. `permissions.can_close_as_duplicate`
        on the show response says whether it is offered.

        A close, so delete-level like the `close` event (#6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - duplicate_of_id
              properties:
                duplicate_of_id:
                  type: integer
                  description: The problem to keep
                closure_notes:
                  type: string
      responses:
        '200':
          description: Closed as a duplicate
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/reopen":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Reopen a closed problem
      description: |
        A closed problem goes back to investigating. Its last root cause and
        resolution are kept as investigation entries and cleared, so a new root
        cause can be recorded; a duplicate becomes its own problem again (the
        incidents it handed over stay where they went). `reason` is required and is
        recorded on the timeline. Service Desk admins only (a tenant admin or a
        Service Desk app admin) — anyone else gets 403. `permissions.can_reopen` on the
        show response says whether it is offered.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Reopened
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/investigation_entries":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Add an investigation entry
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                entry_type:
                  type: string
                  enum:
                  - note
                  - finding
                  - workaround
                  - root_cause
                  - action_taken
                content:
                  type: string
      responses:
        '201':
          description: Added
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/known_error":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Publish as a Known Error
      description: |
        Needs the Knowledge Base approver tier (business admin, Service Desk app
        admin, or a member of the tenant's KB approver groups) — 403 otherwise.
        `workaround` is required; a closed problem cannot be published.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - workaround
              properties:
                workaround:
                  type: string
                known_error_description:
                  type: string
      responses:
        '200':
          description: Published
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
    delete:
      tags:
      - Service Desk Problems
      summary: Unpublish a Known Error
      description: Same approver tier as publishing. Refused once the problem is closed.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unpublished
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/incidents":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Link a ticket as an incident of this problem
      description: A grievance the caller may not handle answers 404, like an unknown
        ticket.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - help_request_id
              properties:
                help_request_id:
                  type: integer
                link_type:
                  type: string
                  enum:
                  - caused_by
                  - related_to
                  - duplicate_of
                  default: caused_by
                notes:
                  type: string
      responses:
        '201':
          description: Linked
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/incidents/{help_request_id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: help_request_id
      in: path
      required: true
      schema:
        type: integer
    delete:
      tags:
      - Service Desk Problems
      summary: Unlink an incident
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unlinked
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/configuration_items":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Link a configuration item
      description: 404 ("Configuration Management (CMDB) is not turned on for this
        Service Desk.") while the tenant has the CMDB module off.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - configuration_item_id
              properties:
                configuration_item_id:
                  type: integer
                notes:
                  type: string
      responses:
        '201':
          description: Linked
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/configuration_items/{configuration_item_id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: configuration_item_id
      in: path
      required: true
      schema:
        type: integer
    delete:
      tags:
      - Service Desk Problems
      summary: Unlink a configuration item
      description: 404 ("Configuration Management (CMDB) is not turned on for this
        Service Desk.") while the tenant has the CMDB module off.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unlinked
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ServiceDeskProblemEnvelope"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/problems/{id}/change_request":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Problems
      summary: Raise the fix change request
      description: |
        Creates a draft normal change from the problem. Offered while Change
        Management is on (404 otherwise), the problem is at root_cause_found and has
        no fix change yet (422 otherwise).
      security:
      - BearerAuth: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  change_request:
                    "$ref": "#/components/schemas/ServiceDeskProblemChangeRequest"
                  problem:
                    "$ref": "#/components/schemas/ServiceDeskProblemDetail"
        '404':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '422':
          "$ref": "#/components/responses/ServiceDeskProblemError"
        '403':
          "$ref": "#/components/responses/ServiceDeskProblemError"
  "/service_desk/changes":
    get:
      tags:
      - Service Desk
      summary: List change requests
      description: |
        Service Desk staff (agents, business admins, Service Desk app admins) see every change in the
        tenant; everyone else sees the changes they raised. `stats` are over the same rows, unfiltered.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: One status or a list (`status[]=`)
        schema:
          type: string
          enum:
          - draft
          - submitted
          - pending_review
          - approved
          - scheduled
          - implementing
          - implemented
          - closed
          - failed
          - cancelled
      - name: change_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - standard
          - normal
          - emergency
      - name: priority
        in: query
        required: false
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: risk_level
        in: query
        required: false
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: assignee_id
        in: query
        required: false
        schema:
          type: integer
      - name: requester_id
        in: query
        required: false
        schema:
          type: integer
      - name: date_from
        in: query
        required: false
        description: Created on or after (YYYY-MM-DD)
        schema:
          type: string
      - name: date_to
        in: query
        required: false
        description: Created on or before (YYYY-MM-DD)
        schema:
          type: string
      - name: q
        in: query
        required: false
        description: Search title, change number and description
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The page of change requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  change_requests:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChangeRequestSummary"
                  stats:
                    type: object
                    properties:
                      total:
                        type: integer
                      draft:
                        type: integer
                      pending_review:
                        type: integer
                      scheduled:
                        type: integer
                      implementing:
                        type: integer
                      closed:
                        type: integer
                      failed:
                        type: integer
                  can_create:
                    type: boolean
                  total_count:
                    type: integer
                  total_pages:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
        '403':
          description: Service Desk not enabled for the caller, or the token lacks
            `read:service_desk`
        '404':
          description: Change Management is switched off for this tenant
    post:
      tags:
      - Service Desk
      summary: Raise a change request (draft)
      description: |
        Anyone who can reach Change Management may raise a change. Fields may be nested under
        `change_request` or sent at the top level. `assignee_id` and `cab_id` are ignored unless the
        caller is Service Desk staff (use `assign` / `route_to_cab`). Attachments are web-only for now.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ChangeRequestInput"
      responses:
        '201':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    get:
      tags:
      - Service Desk
      summary: Get a change request
      description: |
        Readable by staff, the requester, the assignee, and the members and chair of its CAB.
        `permissions` lists the actions the caller may take right now — the buttons the web page shows.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The change request
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  change_request:
                    "$ref": "#/components/schemas/ChangeRequestDetail"
        '404':
          description: Not found, not visible to the caller, or the module is switched
            off
    patch:
      tags:
      - Service Desk
      summary: Edit a change request
      description: |
        Draft or submitted changes only, by the requester or an admin (409 once it has moved on);
        a draft may also be edited by anyone who may submit it.
        `assignee_id` and `cab_id` are ignored for non-staff. Only the requester or an admin may set
        `change_type` to `emergency` — an emergency change is approved the moment it is submitted — so
        anyone else gets 403 and nothing is saved (`permissions.can_make_emergency`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ChangeRequestInput"
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '409':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/submit":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Submit a change for review
      description: 'Submits a draft. For a change the CAB rejected (it returns to
        `submitted` with its CAB still set) this resubmits it to that CAB instead
        — the web''s one Submit button does both. Allowed for the requester, the assignee
        and Service Desk staff. An emergency change submitted by staff or the CAB
        chair is fast-tracked to `approved`; a member''s emergency change goes to
        review like any other.

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/resubmit":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Resubmit a rejected change to its CAB
      description: 'Only for a change the CAB rejected (status `submitted` with a
        CAB). Clears last round''s ballots and opens a new review round (or auto-approves
        under the board''s policy). 422 for anything else.

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/approve":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Approve (CAB decision)
      description: 'Allowed when the caller may carry the decision (ChangeRequest#can_be_approved_by?):
        an admin, or a voting CAB member once the board''s approval rule is met. Never
        the requester (admins excepted).

        '
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/reject":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Reject and return to the requester
      description: |
        Same gate as approve. The change goes back to `submitted` and keeps its CAB, so the requester can resubmit.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rejection_reason
              properties:
                rejection_reason:
                  type: string
                  description: Required — blank is a 422
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/request_more_info":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Ask the requester for more information
      description: 'Same gate as approve. Records the request on the caller''s ballot
        and the timeline.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - details
              properties:
                details:
                  type: string
                  description: Required — blank is a 422
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/vote":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Cast a CAB ballot
      description: 'Voting members (chair or member, not observer) of the change''s
        CAB, while it is `pending_review` and they have not voted. `outcome` is `approved`/`rejected`
        when this ballot carried the change under the board''s rule.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - decision
              properties:
                decision:
                  type: string
                  enum:
                  - approved
                  - rejected
                  - deferred
                  - more_info_requested
                comments:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/route_to_cab":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Route a submitted change to a CAB
      description: 'Service Desk staff. Only a `submitted` change with no CAB. Auto-approves
        when the board''s auto-approval is on and the risk score is within its threshold;
        otherwise opens CAB review.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - cab_id
              properties:
                cab_id:
                  type: integer
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/assign":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Assign the change
      description: 'Service Desk staff. The assignee must be a Service Desk agent
        or admin (422 otherwise).

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - assignee_id
              properties:
                assignee_id:
                  type: integer
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/schedule":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Schedule an approved change
      description: 'Staff or the assignee. Only an `approved` change. Both times ISO
        8601, end after start (422 otherwise). A blackout window in `block` mode refuses
        the schedule (422); in `warn` mode it is booked and the overlap comes back
        in `warnings`.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - scheduled_start_at
              - scheduled_end_at
              properties:
                scheduled_start_at:
                  type: string
                  format: date-time
                scheduled_end_at:
                  type: string
                  format: date-time
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/start_implementation":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Start implementation
      description: 'Staff or the assignee. `scheduled` → `implementing`.

        '
      security:
      - BearerAuth: []
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/complete_implementation":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Complete implementation
      description: 'Staff or the assignee. `implementing` → `implemented`.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                completion_notes:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/fail_implementation":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Mark implementation failed
      description: 'Staff or the assignee. `implementing` → `failed`.

        '
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - failure_notes
              properties:
                failure_notes:
                  type: string
                  description: Required — blank is a 422
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/close":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Close the change
      description: |
        Staff or the assignee. From `implemented` or `failed`; `closure_code` and `closure_notes` are required.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - closure_code
              - closure_notes
              properties:
                closure_code:
                  type: string
                  enum:
                  - successful
                  - successful_with_issues
                  - rolled_back
                  - failed
                  - cancelled
                closure_notes:
                  type: string
                lessons_learned:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/changes/{id}/cancel":
    parameters:
    - "$ref": "#/components/parameters/ChangeRequestId"
    post:
      tags:
      - Service Desk
      summary: Cancel the change
      description: |
        Staff, the assignee or the requester. From draft, submitted, pending_review, approved or scheduled.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                cancel_reason:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/ChangeRequestActionDone"
        '403':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
        '404':
          description: Not found or not visible to the caller
        '422':
          "$ref": "#/components/responses/ChangeRequestActionRefused"
  "/service_desk/access_requests":
    get:
      tags:
      - Service Desk
      summary: List access requests
      description: |
        Access requests the caller may see — the same list the web Access Requests page shows.
        Full admins and Service Desk app admins see every request in the tenant; everyone else
        sees the requests they made, the ones they decided, and the pending ones they are the
        designated approver for (location manager, workspace owner, site owner, support team manager).

        `needs_my_approval_count` is always returned: the pending requests the caller can approve or
        reject right now (every pending request, for a full admin). `counts` are over the chosen
        `scope`, before the `status` filter.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        description: |
          `all` (default) — everything you may see. `mine` — requests you made.
          `approvals` — pending requests waiting on your decision.
        schema:
          type: string
          enum:
          - all
          - mine
          - approvals
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - executed
          - cancelled
          - expired
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The page of access requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  scope:
                    type: string
                    enum:
                    - all
                    - mine
                    - approvals
                  access_requests:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AccessRequestSummary"
                  counts:
                    type: object
                    description: Requests per status within `scope`
                    additionalProperties:
                      type: integer
                    example:
                      pending: 2
                      approved: 0
                      rejected: 1
                      executed: 4
                      cancelled: 0
                      expired: 0
                  needs_my_approval_count:
                    type: integer
                    example: 2
                  page:
                    type: integer
                  per_page:
                    type: integer
                  total_count:
                    type: integer
                  total_pages:
                    type: integer
        '400':
          description: Unknown `scope` or `status`
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AccessRequestError"
        '404':
          description: The Access Requests module is switched off for this tenant
    post:
      tags:
      - Service Desk
      summary: Request access
      description: |
        Opens an access request for the caller, through the same creation service as the web form.
        Pick `target_type` and `target_id` from `GET /service_desk/access_requests/options`.
        When the tenant's zero-touch policy covers the target (e.g. a location in a city you already
        work in, or an open workspace), the request is granted on the spot and comes back `executed`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - target_type
              - target_id
              - justification
              properties:
                target_type:
                  type: string
                  enum:
                  - Location
                  - Workspace
                  - Site
                  - SupportTeam
                  - MarketplaceApp
                  - AssetPro::Category
                target_id:
                  type: integer
                justification:
                  type: string
                  minLength: 10
                  maxLength: 2000
                requested_role:
                  type: string
                  enum:
                  - member
                  - manager
                  - co_manager
      responses:
        '201':
          description: Request opened (or granted instantly)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AccessRequestWriteResponse"
        '422':
          description: |
            Not opened — validation errors (short justification, already a member, a pending request
            already open for this target, a target in another tenant) or a type that cannot be requested.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AccessRequestError"
  "/service_desk/access_requests/options":
    get:
      tags:
      - Service Desk
      summary: What the caller can request
      description: |
        The request catalog the web New Access Request form shows: one entry per kind of access the
        caller can meaningfully ask for, each with its pickable targets (up to 200 per kind, by name).
        A kind with nothing to offer is omitted. `clearance: instant` marks kinds that CAN be granted
        without approval; `items[].instant` says whether that particular target would be.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  roles:
                    type: array
                    items:
                      type: string
                    example:
                    - member
                    - manager
                    - co_manager
                  request_types:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          example: Location
                        title:
                          type: string
                          example: Work at a Location
                        description:
                          type: string
                        icon:
                          type: string
                          example: fas fa-map-marker-alt
                        color:
                          type: string
                          example: primary
                        clearance:
                          type: string
                          enum:
                          - instant
                          - approval
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              subtitle:
                                type: string
                                nullable: true
                              instant:
                                type: boolean
                                description: Present for Location and Workspace targets
  "/service_desk/access_requests/{id}":
    get:
      tags:
      - Service Desk
      summary: Get an access request
      description: The detail page — justification, decision notes, audit trail and
        the actions the caller may take.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AccessRequestId"
      responses:
        '200':
          description: The access request
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  access_request:
                    "$ref": "#/components/schemas/AccessRequestDetail"
        '404':
          description: Not found, or not visible to the caller
  "/service_desk/access_requests/{id}/approve":
    post:
      tags:
      - Service Desk
      summary: Approve an access request
      description: |
        Approves and grants the access in one step. Allowed for a full admin and for the target's
        designated approver while the request is pending. If the grant fails, nothing is granted and
        the request stays pending (422).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AccessRequestId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                approval_notes:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/AccessRequestDecided"
        '403':
          "$ref": "#/components/responses/AccessRequestForbidden"
        '404':
          description: Not found, or not visible to the caller
        '422':
          "$ref": "#/components/responses/AccessRequestDecisionFailed"
  "/service_desk/access_requests/{id}/reject":
    post:
      tags:
      - Service Desk
      summary: Reject an access request
      description: |
        Same gate as approve. The requester is notified.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AccessRequestId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rejection_reason
              properties:
                rejection_reason:
                  type: string
                  description: Required — the requester is told why; blank is a 422
      responses:
        '200':
          "$ref": "#/components/responses/AccessRequestDecided"
        '403':
          "$ref": "#/components/responses/AccessRequestForbidden"
        '404':
          description: Not found, or not visible to the caller
        '422':
          "$ref": "#/components/responses/AccessRequestDecisionFailed"
  "/service_desk/access_requests/{id}/cancel":
    post:
      tags:
      - Service Desk
      summary: Cancel an access request
      description: |
        Allowed for the requester, a full admin or a Service Desk app admin, while the request can still be cancelled.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AccessRequestId"
      responses:
        '200':
          "$ref": "#/components/responses/AccessRequestDecided"
        '403':
          "$ref": "#/components/responses/AccessRequestForbidden"
        '404':
          description: Not found, or not visible to the caller
        '422':
          "$ref": "#/components/responses/AccessRequestDecisionFailed"
  "/service_desk/access_requests/{id}/resubmit":
    post:
      tags:
      - Service Desk
      summary: Ask again
      description: |
        Opens a NEW request for the same target from a rejected, cancelled or expired one — the old
        request keeps its decision. Only the original requester may resubmit. The justification and
        role default to the original's. Every creation rule applies again (e.g. already a member,
        a pending request already open).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AccessRequestId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                justification:
                  type: string
                  minLength: 10
                  maxLength: 2000
                requested_role:
                  type: string
                  enum:
                  - member
                  - manager
                  - co_manager
      responses:
        '201':
          description: The new request
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/AccessRequestWriteResponse"
                - type: object
                  properties:
                    resubmitted_from_id:
                      type: integer
        '403':
          "$ref": "#/components/responses/AccessRequestForbidden"
        '404':
          description: Not found, or not visible to the caller
        '422':
          description: Not resubmittable (`code` `not_resubmittable`) or the new request
            failed validation
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AccessRequestError"
  "/service_desk/cmdb":
    get:
      tags:
      - Service Desk
      summary: List configuration items (CMDB)
      description: |
        The tenant's Configuration Management Database — servers, applications, databases, network gear and
        the services they make up. Same list, filters and counters as the web CMDB page.

        **Who:** Service Desk staff — a help desk agent, a Service Desk app admin or a business admin (403
        otherwise). `can_manage` says whether the viewer may create and edit CIs (Service Desk admins only).
        Answers 404 while the tenant has the CMDB module switched off.

        `stats` counts the WHOLE CMDB, not the filtered page. `first_run` is true when the CMDB is empty and no
        filter is applied.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Search name, CI number and description
        schema:
          type: string
      - name: ci_type
        in: query
        required: false
        description: One type, or several as `ci_type[]=`
        schema:
          type: string
          enum:
          - service
          - server
          - application
          - database
          - network
          - storage
          - other
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - active
          - inactive
          - planned
          - retired
      - name: environment
        in: query
        required: false
        schema:
          type: string
          enum:
          - production
          - staging
          - development
          - dr
      - name: criticality
        in: query
        required: false
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: owner_id
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: A page of configuration items
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  configuration_items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ConfigurationItemSummary"
                  total_count:
                    type: integer
                  total_pages:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
                  stats:
                    type: object
                    properties:
                      total:
                        type: integer
                      active:
                        type: integer
                      inactive:
                        type: integer
                      planned:
                        type: integer
                      retired:
                        type: integer
                  filters_applied:
                    type: boolean
                  first_run:
                    type: boolean
                  sample_data_present:
                    type: boolean
                    description: The CMDB holds example (sample-data) CIs
                  can_manage:
                    type: boolean
                  options:
                    "$ref": "#/components/schemas/ConfigurationItemOptions"
        '403':
          description: Not Service Desk staff, or the token lacks read:service_desk
        '404':
          description: The CMDB module is switched off for this tenant
    post:
      tags:
      - Service Desk
      summary: Create a configuration item
      description: |
        **Who:** Service Desk admins (a business admin or a Service Desk app admin); 403 otherwise. Needs
        write:service_desk. The CI number (`CI-00001`) is generated.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - configuration_item
              properties:
                configuration_item:
                  "$ref": "#/components/schemas/ConfigurationItemInput"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Configuration item created successfully.
                  configuration_item:
                    "$ref": "#/components/schemas/ConfigurationItemDetail"
        '403':
          description: Not a Service Desk admin
        '422':
          description: Validation failed — `errors` carries the messages
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CmdbValidationError"
  "/service_desk/cmdb/search":
    get:
      tags:
      - Service Desk
      summary: Find a configuration item to relate
      description: 'The "add relationship" picker: live (not retired) CIs by name,
        at most 20. Service Desk staff.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
      - name: exclude_ids
        in: query
        required: false
        description: CI ids to leave out (e.g. the CI being edited), up to 50
        schema:
          type: array
          items:
            type: integer
      responses:
        '200':
          description: Matching CIs
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  configuration_items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ConfigurationItemRef"
        '403':
          description: Not Service Desk staff, or the token lacks read:service_desk
  "/service_desk/cmdb/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Service Desk
      summary: Get a configuration item with its relationships
      description: |
        The CI page: details, upstream relationships (the CIs this one depends on), downstream relationships
        (the CIs that depend on it), the impact counters, and the problems and changes linked to it.

        `linked_problems` / `linked_changes` list the 10 most recent with the true `total_count`; each has
        `available: false` and no rows while that module is switched off. `actions` says what the viewer may do
        on this CI — Edit, Retire and Add Relationship disappear once it is retired.

        Service Desk staff; 404 for a CI that is not in this tenant.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The configuration item
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  configuration_item:
                    allOf:
                    - "$ref": "#/components/schemas/ConfigurationItemDetail"
                    - type: object
                      properties:
                        relationships:
                          type: object
                          properties:
                            total_count:
                              type: integer
                            upstream:
                              type: array
                              items:
                                "$ref": "#/components/schemas/ConfigurationItemRelationship"
                            downstream:
                              type: array
                              items:
                                "$ref": "#/components/schemas/ConfigurationItemRelationship"
                        impact:
                          type: object
                          properties:
                            downstream:
                              type: integer
                              description: Live CIs that depend on this one
                            open_problems:
                              type: integer
                            open_changes:
                              type: integer
                        linked_problems:
                          type: object
                          properties:
                            available:
                              type: boolean
                            total_count:
                              type: integer
                            items:
                              type: array
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                  problem_number:
                                    type: string
                                  title:
                                    type: string
                                  status:
                                    type: string
                                  priority:
                                    type: string
                        linked_changes:
                          type: object
                          properties:
                            available:
                              type: boolean
                            total_count:
                              type: integer
                            items:
                              type: array
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                  change_number:
                                    type: string
                                  title:
                                    type: string
                                  change_type:
                                    type: string
                                  status:
                                    type: string
                        actions:
                          type: object
                          properties:
                            edit:
                              type: boolean
                            retire:
                              type: boolean
                            add_relationship:
                              type: boolean
                            remove_relationship:
                              type: boolean
                  options:
                    "$ref": "#/components/schemas/ConfigurationItemOptions"
        '403':
          description: Not Service Desk staff
        '404':
          description: Not found, or the CMDB module is switched off
    patch:
      tags:
      - Service Desk
      summary: Update a configuration item
      description: |
        Service Desk admins only. Send only the fields to change. `attributes_data: {}` clears the custom
        attributes; leaving the key out keeps them.

        Setting `status` to `retired` on a CI that is not retired yet retires it as
        `POST /service_desk/cmdb/{id}/retire` does, so that update is delete-level
        (owner decision 2026-10-04, #6721): on top of `write:service_desk` it
        requires the `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - configuration_item
              properties:
                configuration_item:
                  "$ref": "#/components/schemas/ConfigurationItemInput"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Configuration item updated.
                  configuration_item:
                    "$ref": "#/components/schemas/ConfigurationItemDetail"
        '403':
          description: Not a Service Desk admin
        '404':
          description: Not found
        '422':
          description: Validation failed
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CmdbValidationError"
  "/service_desk/cmdb/{id}/retire":
    post:
      tags:
      - Service Desk
      summary: Retire a configuration item
      description: |
        Marks the CI as no longer in use (status `retired`). Service Desk admins only.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Retired
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Configuration item retired.
                  configuration_item:
                    "$ref": "#/components/schemas/ConfigurationItemDetail"
        '403':
          description: Not a Service Desk admin
        '404':
          description: Not found
        '422':
          description: Could not be retired
  "/service_desk/cmdb/{id}/relationships":
    post:
      tags:
      - Service Desk
      summary: Relate another configuration item
      description: |
        Service Desk admins only. `direction: child` makes the related CI a child of this one (it depends on
        this CI — downstream); `parent` makes it a parent (this CI depends on it — upstream). A relationship
        that would close a loop of the same type is refused.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - related_ci_id
              - direction
              - relationship_type
              properties:
                related_ci_id:
                  type: integer
                direction:
                  type: string
                  enum:
                  - child
                  - parent
                relationship_type:
                  type: string
                  enum:
                  - depends_on
                  - runs_on
                  - part_of
                  - connects_to
                  - uses
                notes:
                  type: string
      responses:
        '201':
          description: Related
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Relationship added.
                  direction:
                    type: string
                    enum:
                    - upstream
                    - downstream
                    description: Where the new row appears on this CI
                  relationship:
                    "$ref": "#/components/schemas/ConfigurationItemRelationship"
        '403':
          description: Not a Service Desk admin
        '404':
          description: This CI, or the related CI, is not in this tenant
        '422':
          description: Invalid direction, type, duplicate, self-reference or cycle
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CmdbValidationError"
  "/service_desk/cmdb/{id}/relationships/{relationship_id}":
    delete:
      tags:
      - Service Desk
      summary: Remove a relationship
      description: Service Desk admins only. 404 when the relationship does not belong
        to this CI.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: relationship_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Relationship removed.
        '403':
          description: Not a Service Desk admin
        '404':
          description: Not found
  "/service_desk/grievances":
    get:
      tags:
      - Service Desk Grievances
      summary: List grievances
      description: |
        A handler sees the tenant's queue; everyone else sees the grievances they
        filed or are assigned. Ordered by stage deadline, then newest filed. The
        stats (total, open, overdue, per-stage) follow search and priority but not
        stage/overdue, as the web page's filter pills do. `priority` is ignored
        while the tenant hides priority from this caller.
      security:
      - BearerAuth: []
      parameters:
      - name: stage
        in: query
        required: false
        description: A stage key from `stages`
        schema:
          type: string
      - name: overdue
        in: query
        required: false
        description: Only grievances past their stage deadline
        schema:
          type: boolean
      - name: priority
        in: query
        required: false
        description: One priority, or several as priority[]
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: search
        in: query
        required: false
        description: Matches title or description
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: A page of grievances
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  is_handler:
                    type: boolean
                    description: Whether the caller works the whole queue
                  stats:
                    type: object
                    properties:
                      total:
                        type: integer
                      open:
                        type: integer
                      overdue:
                        type: integer
                      by_stage:
                        type: object
                        additionalProperties:
                          type: integer
                  stages:
                    type: array
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                        name:
                          type: string
                        order:
                          type: integer
                        sla_hours:
                          type: integer
                          nullable: true
                  total_count:
                    type: integer
                  total_pages:
                    type: integer
                  page:
                    type: integer
                  per_page:
                    type: integer
                  grievances:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ServiceDeskGrievanceSummary"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
    post:
      tags:
      - Service Desk Grievances
      summary: File a grievance
      description: |
        Files a grievance for the caller at the tenant's first workflow stage, with
        that stage's deadline. The tenant's required grievance questions are
        enforced. Assignee, team and department are honoured for handlers only.
        `anonymous: true` is refused (422) — a grievance's filer must be able to
        follow it; anonymous reporting is Speak Up's. Files go as multipart
        `attachments[]` (10 MB each, within the tenant's per-request limit); files
        that could not be attached are named in `attachments_warning`. Send an
        `Idempotency-Key` header so a retried Submit does not file twice.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - grievance
              properties:
                grievance:
                  "$ref": "#/components/schemas/ServiceDeskGrievanceInput"
          multipart/form-data:
            schema:
              type: object
              properties:
                grievance:
                  "$ref": "#/components/schemas/ServiceDeskGrievanceInput"
                attachments:
                  type: array
                  items:
                    type: string
                    format: binary
      responses:
        '201':
          description: Filed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Grievance filed successfully.
                  attachments_warning:
                    type: string
                    nullable: true
                  grievance:
                    "$ref": "#/components/schemas/ServiceDeskGrievanceDetail"
        '400':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '409':
          description: Idempotency conflict — the `Idempotency-Key` was reused with
            a different body (`idempotency_conflict`), or the first request with it
            is still running (`idempotency_in_progress`, with `Retry-After`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/form_options":
    get:
      tags:
      - Service Desk Grievances
      summary: What the filing and edit forms draw
      description: |
        The tenant's grievance questions (the same definitions the web form draws),
        the priority choices, the first stage and the attachment limits. Handlers
        also get the team and assignee pickers; pass `grievance_id` when editing so
        the grievance's own team and assignee are always offered.
      security:
      - BearerAuth: []
      parameters:
      - name: grievance_id
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Form options
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  priorities:
                    type: array
                    items:
                      type: string
                  default_priority:
                    type: string
                  initial_stage:
                    "$ref": "#/components/schemas/ServiceDeskGrievanceStageRef"
                  custom_fields:
                    type: array
                    items:
                      type: object
                      description: A ServiceDeskCustomField definition (label, field_type,
                        required, options…)
                  attachments:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      max_per_request:
                        type: integer
                      max_file_bytes:
                        type: integer
                  anonymous_allowed:
                    type: boolean
                    example: false
                  teams:
                    type: array
                    description: Handlers only
                    items:
                      "$ref": "#/components/schemas/ServiceDeskGrievancePerson"
                  assignees:
                    type: array
                    description: Handlers only
                    items:
                      "$ref": "#/components/schemas/ServiceDeskGrievancePerson"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Service Desk Grievances
      summary: Get a grievance
      description: |
        The stage, its deadline, the stage history, the comments, the answered
        questions and the documents — what the web page shows this caller. The
        requester and a non-handler assignee get the stage history as plain
        "moved from X to Y" lines and the comment thread without internal notes.
        Records a "viewed" audit entry.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The grievance
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  grievance:
                    "$ref": "#/components/schemas/ServiceDeskGrievanceDetail"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
    patch:
      tags:
      - Service Desk Grievances
      summary: Edit a grievance (handlers)
      description: |
        `custom_fields` merge over the stored answers, so an answer under a
        question an admin has since switched off is kept. The audit entry names
        the changed fields, never their values.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - grievance
              properties:
                grievance:
                  "$ref": "#/components/schemas/ServiceDeskGrievanceInput"
      responses:
        '200':
          "$ref": "#/components/responses/ServiceDeskGrievanceUpdated"
        '403':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '422':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/{id}/advance_stage":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Grievances
      summary: Advance to a later stage (handlers)
      description: |
        `stage` must be one of the grievance's `allowed_transitions` (the next
        stage, Resolved, or Closed). A stage with a named approver or approver
        group can only be entered by them or an admin. Sets the new stage's
        deadline and tells the grievant when the tenant has that switched on.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - stage
              properties:
                stage:
                  type: string
                notes:
                  type: string
                  description: Staff-only note recorded on the stage history
      responses:
        '200':
          "$ref": "#/components/responses/ServiceDeskGrievanceUpdated"
        '403':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '422':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/{id}/revert_stage":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Grievances
      summary: Move back to an earlier stage (admins)
      description: Business admins and Service Desk admins only. `reason` is required
        and recorded on the grievance.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - stage
              - reason
              properties:
                stage:
                  type: string
                  description: One of the grievance's `revert_targets`
                reason:
                  type: string
      responses:
        '200':
          "$ref": "#/components/responses/ServiceDeskGrievanceUpdated"
        '403':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '422':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/{id}/comments":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Grievances
      summary: Comment on a grievance
      description: |
        Anyone who can read the grievance. `internal: true` (hidden from the
        requester) is for handlers only: anyone else sending it is REFUSED with
        403 rather than having the note published to the grievant. Send an
        `Idempotency-Key` header so a retried Post does not comment twice — a
        retry with the same key and body replays the first 201.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                internal:
                  type: boolean
                  default: false
      responses:
        '201':
          description: Posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  comment:
                    "$ref": "#/components/schemas/ServiceDeskGrievanceComment"
        '403':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '409':
          description: Idempotency conflict — the `Idempotency-Key` was reused with
            a different body (`idempotency_conflict`), or the first request with it
            is still running (`idempotency_in_progress`, with `Retry-After`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/{id}/documents":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Service Desk Grievances
      summary: Add documents
      description: Handlers, or the grievant on their own grievance, up to the tenant's
        per-grievance file limit (10 MB a file).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                documents:
                  type: array
                  items:
                    type: string
                    format: binary
      responses:
        '201':
          "$ref": "#/components/responses/ServiceDeskGrievanceUpdated"
        '403':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '422':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/service_desk/grievances/{id}/documents/{attachment_id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: attachment_id
      in: path
      required: true
      schema:
        type: integer
    delete:
      tags:
      - Service Desk Grievances
      summary: Remove a document (admins)
      description: |
        Business admins and Service Desk admins only — the file is deleted permanently.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:service_desk` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          "$ref": "#/components/responses/ServiceDeskGrievanceUpdated"
        '403':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
        '404':
          "$ref": "#/components/responses/ServiceDeskGrievanceError"
  "/desktop/bootstrap":
    get:
      tags:
      - Desktop
      summary: Everything the desktop app needs to draw itself, in one answer
      description: |
        Call once right after login (and again when connectivity returns if the
        first call never answered). Composed from the SAME builders the per-app
        endpoints render — `apps.chat` is the body of `GET /chat/config`,
        `apps.recorder` of `GET /recorder/config`, `apps.ai_notepad.config` /
        `.preferences` of the two AI Notepad GETs, `apps.ask_ai` of
        `GET /ask_ai/settings` (minus `success`), `branding` of
        `GET /branding/theme` — so a client that already speaks those endpoints
        feeds the blocks to the same code.

        **Never 403s.** A tenant with no desktop app at all answers 200 with an
        empty `rail.landing`; that is the answer the client draws its
        "nothing is switched on for the desktop app yet" shell from. Per-app
        scopes are REPORTED (`denial_reason: insufficient_scope`), never
        enforced here — the per-app endpoints enforce them on use.

        **`rail.apps`** follows the company's order set under General Settings
        → OneMango Desktop. The default is `chat`, `ask_ai`, `ai_notepad`,
        `recorder`, `people`. Reordering does not change access. Each slot carries `enabled` (the
        tenant has the app), `accessible` (this person, with this token, may
        open it) and `denial_reason` — `app_not_enabled`, `app_not_accessible`
        (outside the app's audience / not yet published), `upgrade_required`
        (Ask AI below the Standard tier), `insufficient_scope`, `unavailable`
        (the app is the person's but its boot block failed to build — the
        rest of the answer stands; ask again later), or `null`.
        `people` is the Chat app's directory column and rides Chat's gate.
        `rail.landing` is the first accessible slot, or `null`.

        `bootstrap_version` is bumped only when a client would need new code
        to read the answer; additive keys never bump it.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The desktop boot answer
          content:
            application/json:
              schema:
                type: object
                required:
                - bootstrap_version
                - generated_at
                - user
                - business
                - rail
                - apps
                - branding
                - realtime
                properties:
                  bootstrap_version:
                    type: integer
                    example: 1
                  generated_at:
                    type: string
                    format: date-time
                  user:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      first_name:
                        type: string
                        nullable: true
                      last_name:
                        type: string
                        nullable: true
                      email:
                        type: string
                      avatar_url:
                        type: string
                        nullable: true
                      time_zone:
                        type: string
                        nullable: true
                        description: |
                          The zone the caller reads — their own choice, else their
                          place's, else the company's — as stored: usually a Rails
                          name ("Central Time (US & Canada)"), sometimes an IANA id.
                        example: Central Time (US & Canada)
                      time_zone_iana:
                        type: string
                        nullable: true
                        description: |
                          The same zone as a canonical IANA id, for Intl and
                          moment-timezone, which know no Rails name. Null only when
                          the zone can't be resolved. Canonical: a zone tzdata merged
                          with another of the same clock answers that one
                          ("Copenhagen" is "Europe/Berlin"), so compute times with
                          it rather than printing it as a place.
                        example: America/Chicago
                      time_zone_set:
                        type: boolean
                        description: Whether the caller chose a zone themselves (`false`
                          — `time_zone` follows their place or the company).
                      language:
                        type: string
                        description: |
                          The language content reaches the caller in — their own
                          setting, else their place's, else the company default.
                          Never null since Knowledge Layer P4 (was the stored
                          setting, null when unset).
                        example: en
                      language_set:
                        type: boolean
                        description: Whether the caller chose a language themselves
                          (`false` is the profile's "Not set").
                      admin:
                        type: boolean
                        description: Whether the person can enable apps themselves
                          (the Apps Marketplace is an admin surface).
                  business:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      subdomain:
                        type: string
                      time_zone:
                        type: string
                      week_start_day:
                        type: string
                        example: monday
                      logo_url:
                        type: string
                        nullable: true
                      apps_marketplace_path:
                        type: string
                        example: "/admin/app_marketplace"
                        description: Where an admin turns a desktop app on. A path
                          — build it against the host you signed in to.
                  rail:
                    type: object
                    properties:
                      landing:
                        type: string
                        nullable: true
                        enum:
                        - chat
                        - ask_ai
                        - ai_notepad
                        - recorder
                        - people
                      apps:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                              enum:
                              - chat
                              - ask_ai
                              - ai_notepad
                              - recorder
                              - people
                            slug:
                              type: string
                              description: The marketplace app the slot belongs to.
                            enabled:
                              type: boolean
                            accessible:
                              type: boolean
                            denial_reason:
                              type: string
                              nullable: true
                              enum:
                              - app_not_enabled
                              - app_not_accessible
                              - upgrade_required
                              - insufficient_scope
                              - unavailable
                  apps:
                    type: object
                    description: Each accessible app's boot block; `null` for a slot
                      the person may not open.
                    properties:
                      chat:
                        type: object
                        nullable: true
                        description: The body of GET /chat/config.
                      ask_ai:
                        type: object
                        nullable: true
                        description: The body of GET /ask_ai/settings without `success`.
                      ai_notepad:
                        type: object
                        nullable: true
                        properties:
                          config:
                            type: object
                            description: The `ai_notepad` block of GET /ai_notepad/config.
                          preferences:
                            type: object
                            description: The body of GET /ai_notepad/preferences.
                      recorder:
                        type: object
                        nullable: true
                        description: The `recorder` block of GET /recorder/config.
                  branding:
                    type: object
                    required:
                    - theme
                    - business_id
                    properties:
                      theme:
                        type: object
                        required:
                        - tokens
                        - branding
                        - brand_fonts
                        - palette
                        properties:
                          tokens:
                            type: object
                            description: Every theme token, defaults overlaid with
                              the tenant's saved values. Additive over time — treat
                              unknown keys as forward compatibility.
                            additionalProperties: true
                            properties:
                              brand_primary:
                                type: string
                                example: "#3066b3"
                              font_heading:
                                type: string
                              font_body:
                                type: string
                              font_size_base:
                                type: string
                                example: 15px
                              text_primary:
                                type: string
                              text_muted:
                                type: string
                              heading_color:
                                type: string
                              page_bg:
                                type: string
                              card_bg:
                                type: string
                              modal_bg:
                                type: string
                              navbar_bg:
                                type: string
                              navbar_text:
                                type: string
                                description: Derived from `navbar_bg` when the admin
                                  left it unset.
                              sidebar_text:
                                type: string
                              sidebar_rail_bg:
                                type: string
                              sidebar_rail_active_bg:
                                type: string
                              border_color:
                                type: string
                              border_radius:
                                type: string
                              shadow_color:
                                type: string
                              style_family:
                                type: string
                                description: The style family, which owns corner radius,
                                  card style and density.
                          branding:
                            type: object
                            properties:
                              logo_url:
                                type: string
                                nullable: true
                              favicon_url:
                                type: string
                                nullable: true
                              sidebar_logo_bg_color:
                                type: string
                                nullable: true
                          custom_css:
                            type: string
                            nullable: true
                            description: The tenant's raw custom stylesheet, kept
                              apart from the tokens because it is not a CSS variable.
                          brand_fonts:
                            type: array
                            description: The uploaded brand fonts the font tokens
                              name, with their files' URLs on this origin. Empty when
                              the tokens name none.
                            items:
                              type: object
                              properties:
                                css_family:
                                  type: string
                                name:
                                  type: string
                                category:
                                  type: string
                                faces:
                                  type: array
                                  items:
                                    type: object
                                    properties:
                                      url:
                                        type: string
                                      weight:
                                        type: integer
                                        example: 400
                                      style:
                                        type: string
                                        enum:
                                        - normal
                                        - italic
                                      format:
                                        type: string
                          palette:
                            type: array
                            description: Every colour the company's colour choosers
                              offer — brand, accent, the colours it added and the
                              ten named ones — each with the fill and the text colour
                              that reads on it, and the colour as a mark on a card
                              in the light and dark themes. A record that stores a
                              colour stores its `key`.
                            items:
                              type: object
                              properties:
                                key:
                                  type: string
                                name:
                                  type: string
                                group:
                                  type: string
                                color:
                                  type: string
                                fill:
                                  type: string
                                ink:
                                  type: string
                                mark:
                                  type: string
                                mark_dark:
                                  type: string
                      business_id:
                        type: integer
                      updated_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: When the theme (or, when it was never saved,
                          the business) last changed.
                  realtime:
                    type: object
                    description: The Pusher bootstrap (key, cluster, user_id, business_id)
                      — the `pusher` block of GET /chat/config, present whether or
                      not the tenant has Chat, because the Recorder and AI Notepad
                      inbox channels authorise at their own endpoints.
                    properties:
                      pusher:
                        type: object
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/branding/theme":
    get:
      tags:
      - Branding
      summary: The business's theme tokens, logo, fonts and palette
      description: |
        The values an admin sets on Theme Settings, resolved for the caller's
        business, so a web or desktop client can set its CSS custom properties
        at runtime and re-theme per tenant without a rebuild. A business that
        never saved a theme gets the full default token set. Read-only; carries
        no secrets.

        Each key under `theme.tokens` maps one-to-one to a CSS custom property
        named `--{key-dasherized}` (`brand_primary` → `--brand-primary`), the
        same mapping the server's own stylesheet uses.

        The answer carries an `ETag` and `Last-Modified`; send them back
        (`If-None-Match` / `If-Modified-Since`) and an unchanged theme answers
        304 with no body. A new logo, favicon or brand font changes the
        validators even when the theme row did not change.

        **Token scopes:** none — any signed-in token may read its own
        business's theme.
      security:
      - BearerAuth: []
      parameters:
      - name: If-None-Match
        in: header
        required: false
        description: The `ETag` of the copy the client holds.
        schema:
          type: string
      - name: If-Modified-Since
        in: header
        required: false
        description: The `Last-Modified` of the copy the client holds.
        schema:
          type: string
      responses:
        '200':
          description: The theme.
          headers:
            ETag:
              schema:
                type: string
            Last-Modified:
              schema:
                type: string
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BrandingTheme"
        '304':
          description: The client's copy is current.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/tasks/config":
    get:
      tags:
      - Tasks
      summary: What the create-task form may offer this person
      description: |
        Always 200 for a signed-in person, so a client hides its "Create a
        task" control instead of offering one that is refused on submit.
        `enabled` says the Tasks app is open to the caller; `can_create` adds
        the organization's self-assignment rule (managers and Tasks admins
        always, everyone else while self-assignment is allowed).

        **Token scopes:** `write:tasks` (satisfied by `write:own_tasks`).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The form's options.
          content:
            application/json:
              schema:
                type: object
                required:
                - tasks
                properties:
                  tasks:
                    type: object
                    required:
                    - enabled
                    - can_create
                    - priority_enabled
                    - priorities
                    - categories
                    properties:
                      enabled:
                        type: boolean
                      can_create:
                        type: boolean
                      priority_enabled:
                        type: boolean
                        description: The organization's priority system is on. When
                          off, every task is stored as `normal`.
                      priorities:
                        type: array
                        items:
                          type: string
                          enum:
                          - normal
                          - important
                          - critical
                      categories:
                        type: array
                        items:
                          type: string
                          enum:
                          - Work
                          - Personal
                          - Project
                          - Meeting
                          - Follow-up
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` — "Required scope: write:tasks".'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/tasks":
    post:
      tags:
      - Tasks
      summary: Create a task assigned to the caller
      description: |
        Creates a simple, pending My Task assigned to and by the caller. No
        notification is sent — the task is the caller's own.

        A `source` naming a chat message is recorded on the task only when the
        caller can still see that message (a room of this business they are a
        member of, the message not deleted). When it cannot be found the task
        is saved without the link and `warnings` says so.

        An `Idempotency-Key` retry replays the first 201 instead of writing a
        second task.

        **Token scopes:** `write:tasks` (satisfied by `write:own_tasks`).
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A key you choose per create; a retry with the same key replays
          the first answer.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              description: Each field must be a single value; a list or an object
                reads as absent.
              properties:
                title:
                  type: string
                  maxLength: 255
                  description: Trimmed and cut to 255 characters (then named in `truncated_fields`).
                description:
                  type: string
                  maxLength: 10000
                  description: Cut to 10,000 characters (then named in `truncated_fields`).
                due_on:
                  type: string
                  format: date
                  description: YYYY-MM-DD, read in the caller's time zone. Alone,
                    the task is due any time that day.
                due_time:
                  type: string
                  pattern: "^([01]\\d|2[0-3]):[0-5]\\d$"
                  description: HH:MM, with `due_on` — the task is due at that minute.
                priority:
                  type: string
                  enum:
                  - normal
                  - important
                  - critical
                  default: normal
                  description: Stored as `normal` while the organization's priority
                    system is off.
                category:
                  type: string
                  enum:
                  - Work
                  - Personal
                  - Project
                  - Meeting
                  - Follow-up
                  description: Blank stores `general`.
                source:
                  type: object
                  description: Where the task came from.
                  required:
                  - type
                  properties:
                    type:
                      type: string
                      enum:
                      - chat_message
                    room_id:
                      type: integer
                    message_id:
                      type: integer
      responses:
        '201':
          description: The task was created.
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      priority:
                        type: string
                      category:
                        type: string
                      status:
                        type: string
                        example: pending
                      due_at:
                        type: string
                        format: date-time
                        nullable: true
                      due_on:
                        type: string
                        format: date
                        nullable: true
                      url_in_app:
                        type: string
                        description: The task's page in the web app, a path on the
                          tenant's host.
                  warnings:
                    type: array
                    description: Present when the source message could not be linked.
                    items:
                      type: string
                  truncated_fields:
                    type: array
                    description: Present when `title` or `description` was cut.
                    items:
                      type: string
                      enum:
                      - title
                      - description
        '400':
          description: '`missing_params` — "Title is required".'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:tasks"),
            `tasks_not_enabled` (the Tasks app is not open to the caller) or `self_assignment_disabled`
            (creating your own tasks is turned off for the organization).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`idempotency_conflict` — this Idempotency-Key was used for
            a different request."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`invalid_priority`, `invalid_category`, `invalid_due_date`
            (not YYYY-MM-DD and HH:MM, or a year outside 1000–9999), `invalid_source`
            (a source type other than `chat_message`) or `validation_failed`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '500':
          description: "`task_create_failed` — no task was written; the error is reported."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: "`task_create_unavailable` — a passing infrastructure failure;
            no task was written. Try again."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/users":
    get:
      tags:
      - Users
      summary: List people in the caller's business
      description: |
        The people directory as a page of rows: HRIS syncs, the mobile roster,
        partner integrations and One Mango Desktop's People tab all read it.
        Service accounts and AI-agent principals are never listed.

        **Who sees what.** An admin, and a machine token with no person behind
        it (an HRIS sync an admin minted), get every field and may ask for
        deactivated (`active`) and former (`include_deleted`) members. Everyone
        else sees active members only, with each field the directory hides from
        them answered as null (an empty list for `locations`) — and a search
        arm, filter or sort on a hidden field is ignored rather than applied.

        Each row carries the person's chat presence, so a client draws live
        dots without a second call.

        **Token scopes:** `read:people_directory` (`read:users` implies it).
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        description: Page number, 1 to 10,000.
        schema:
          type: integer
          minimum: 1
          maximum: 10000
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page, 1 to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: search
        in: query
        required: false
        description: Matches the full name, name, preferred name or employee UID,
          and the email address where the caller's directory shows email.
        schema:
          type: string
      - name: department
        in: query
        required: false
        description: A department name to match (contains, case-insensitive). Returns
          the people in every matching department of this organization and in the
          departments beneath it. Applied only where the directory shows department.
        schema:
          type: string
      - name: employment_status
        in: query
        required: false
        description: An exact employment status. Applied only where the directory
          shows it.
        schema:
          type: string
      - name: updated_after
        in: query
        required: false
        description: Only people whose record changed after this time. An unreadable
          value is ignored.
        schema:
          type: string
          format: date-time
      - name: active
        in: query
        required: false
        description: Full view only — `true` for active members, `false` for deactivated
          ones. Others always get active members.
        schema:
          type: boolean
      - name: include_deleted
        in: query
        required: false
        description: Full view only — include people who have left the business.
        schema:
          type: boolean
          default: false
      - name: sort_by
        in: query
        required: false
        description: "`email` and `hire_date` sort by name where the directory hides
          them; any other value sorts by name."
        schema:
          type: string
          enum:
          - name
          - email
          - hire_date
          - created_at
          - updated_at
          default: name
      - name: sort_order
        in: query
        required: false
        schema:
          type: string
          enum:
          - asc
          - desc
          default: asc
      responses:
        '200':
          description: A page of people.
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - users
                - meta
                properties:
                  users:
                    type: array
                    items:
                      "$ref": "#/components/schemas/UsersDirectoryRow"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '400':
          description: "`invalid_integer_format` or `invalid_integer_value` (`page`
            or `per_page` is not a positive integer), `page_limit_exceeded` (over
            10,000) or `per_page_limit_exceeded` (over 100)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` — "Required scope: read:people_directory".'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/shared":
    get:
      tags:
      - Ask AI
      summary: List shared conversations
      description: Requires read:shared_ai_conversations, active Ask AI access and
        explicit membership in this business. Shared conversations are separate from
        personal AI history and memory. Responses are private and non-cacheable.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        status:
                          type: string
                          enum:
                          - active
                          - invited
                        role:
                          type: string
                          enum:
                          - owner
                          - contributor
                          - viewer
                        updated_at:
                          type: string
                          format: date-time
                        url:
                          type: string
                  page:
                    type: integer
                  total_pages:
                    type: integer
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
    post:
      tags:
      - Ask AI
      summary: Confirm a reviewed sharing preview
      description: Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. Explicit confirmation
        is required. Repeating the same valid preview returns the same shared continuation;
        it never publishes the original private transcript.
      security:
      - BearerAuth: []
      responses:
        '201':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - active
                        - invited
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                preview_token:
                  type: string
                confirmed:
                  type: boolean
              required:
              - preview_token
              - confirmed
  "/ask_ai/shared/people":
    get:
      tags:
      - Ask AI
      summary: Find eligible coworkers
      description: Requires read:shared_ai_conversations, active Ask AI access and
        explicit membership in this business. Shared conversations are separate from
        personal AI history and memory. Responses are private and non-cacheable. Returns
        up to 50 active coworkers with Ask AI access.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        text:
                          type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
          maxLength: 100
  "/ask_ai/shared/preview":
    post:
      tags:
      - Ask AI
      summary: Preview a reviewed private-conversation summary
      description: Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. The caller must
        own the private source. Only the explicitly reviewed summary is copied. Attachments,
        hidden prompts, memory, tool traces and unreviewed assistant answers are excluded.
        The encrypted preview expires after 15 minutes and is bound to this actor,
        business and source revision. At most seven coworkers can be invited.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  preview:
                    type: object
                  preview_token:
                    type: string
                  exclusions:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                source_id:
                  type: integer
                  minimum: 1
                title:
                  type: string
                  minLength: 1
                  maxLength: 120
                summary:
                  type: string
                  minLength: 1
                  maxLength: 12000
                participant_ids:
                  type: array
                  items:
                    type: integer
                    minimum: 1
                  minItems: 1
                  maxItems: 7
                  uniqueItems: true
                role:
                  type: string
                  enum:
                  - viewer
                  - contributor
              required:
              - source_id
              - title
              - summary
              - participant_ids
              - role
  "/ask_ai/shared/{id}":
    get:
      tags:
      - Ask AI
      summary: Read shared messages or an invitation
      description: Requires read:shared_ai_conversations, active Ask AI access and
        explicit membership in this business. Shared conversations are separate from
        personal AI history and memory. Responses are private and non-cacheable. Pending
        invitees receive only conversation title, role, status and invitation:true.
        Accepted participants receive up to 100 currently visible messages, ordered
        by sequence. Source-derived content is rechecked against every active participant;
        unavailable sources are withheld. Search excludes withheld content. The next_before
        cursor retrieves older messages and is null when no older message is visible
        to the caller. The opaque version supports polling. Private refusal guidance
        is visible only to its sender.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    conversation:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        status:
                          type: string
                          enum:
                          - active
                          - invited
                        role:
                          type: string
                          enum:
                          - owner
                          - contributor
                          - viewer
                        updated_at:
                          type: string
                          format: date-time
                        url:
                          type: string
                    messages:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                          sequence:
                            type: integer
                          role:
                            type: string
                            enum:
                            - user
                            - assistant
                          content:
                            type: string
                          author_id:
                            type: integer
                          created_at:
                            type: string
                            format: date-time
                          private:
                            type: boolean
                          unavailable:
                            type: boolean
                    participants:
                      type: array
                      items:
                        type: object
                        properties:
                          user_id:
                            type: integer
                          name:
                            type: string
                          role:
                            type: string
                          status:
                            type: string
                          available:
                            type: boolean
                    version:
                      type: string
                    permissions:
                      type: object
                      properties:
                        can_send:
                          type: boolean
                    turns:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                          actor_id:
                            type: integer
                          status:
                            type: string
                            enum:
                            - queued
                            - processing
                            - failed
                            - cancelled
                            - private
                          reason:
                            type: string
                            nullable: true
                          can_retry:
                            type: boolean
                    next_before:
                      type: integer
                      nullable: true
                    page_limit:
                      type: integer
                      enum:
                      - 100
                - type: object
                  properties:
                    conversation:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        status:
                          type: string
                          enum:
                          - active
                          - invited
                        role:
                          type: string
                          enum:
                          - owner
                          - contributor
                          - viewer
                        updated_at:
                          type: string
                          format: date-time
                        url:
                          type: string
                    invitation:
                      type: boolean
                      enum:
                      - true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: q
        in: query
        required: false
        schema:
          type: string
          maxLength: 100
      - name: before
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
  "/ask_ai/shared/{id}/accept":
    post:
      tags:
      - Ask AI
      summary: Accept a pending invitation
      description: Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. Only the named
        invitee may accept. Transcript access begins after acceptance.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - active
                        - invited
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/ask_ai/shared/{id}/decline":
    post:
      tags:
      - Ask AI
      summary: Decline a pending invitation
      description: Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. Only the named
        invitee may decline. The conversation leaves the invitee's list, and the owner
        sees the invitation as declined; the owner may invite the person again.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - declined
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/ask_ai/shared/{id}/participants":
    get:
      tags:
      - Ask AI
      summary: Read participant management details
      description: Requires read:shared_ai_conversations, active Ask AI access and
        explicit membership in this business. Shared conversations are separate from
        personal AI history and memory. Responses are private and non-cacheable. Owner
        only.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  participants:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id:
                          type: integer
                        name:
                          type: string
                        role:
                          type: string
                        status:
                          type: string
                        available:
                          type: boolean
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
    post:
      tags:
      - Ask AI
      summary: Invite, revoke or change a participant role
      description: Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. Owner only. The
        owner cannot revoke or alter their own role here. Revocation blocks subsequent
        reads, search, exports and generation. Re-invitation requires acceptance again.
        Already copied or exported content cannot be recalled.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - active
                        - invited
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                  minimum: 1
                participant_action:
                  type: string
                  enum:
                  - invite
                  - revoke
                  - role
                role:
                  type: string
                  enum:
                  - viewer
                  - contributor
                  description: Required for invite or role.
              required:
              - user_id
              - participant_action
  "/ask_ai/shared/{id}/messages":
    post:
      tags:
      - Ask AI
      summary: Queue a shared AI reply
      description: 'Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. Owner or contributor
        only. Request UUIDs are idempotent per conversation and bound to the sender.
        Limits: eight pending turns per conversation and 30 messages per sender per
        hour, plus the business AI budget. Turns execute in order under the actual
        sender. The first source adapter supports a Service Desk ticket only when
        every active participant can read it. Other private sources and actions require
        a private conversation. Permissions and source revisions are rechecked before
        persisting an answer. Poll the conversation for progress.'
      security:
      - BearerAuth: []
      responses:
        '202':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - active
                        - invited
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  turn:
                    type: object
                    properties:
                      id:
                        type: integer
                      actor_id:
                        type: integer
                      status:
                        type: string
                        enum:
                        - queued
                        - processing
                        - failed
                        - cancelled
                        - private
                      reason:
                        type: string
                        nullable: true
                      can_retry:
                        type: boolean
                    nullable: true
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message:
                  type: string
                  minLength: 1
                  maxLength: 8000
                request_key:
                  type: string
                  format: uuid
                ticket_id:
                  type: integer
                  minimum: 1
                  description: Optional Service Desk source ticket.
              required:
              - message
              - request_key
  "/ask_ai/shared/{id}/turns/{turn_id}/retry":
    post:
      tags:
      - Ask AI
      summary: Retry the sender’s failed reply
      description: Requires write:shared_ai_conversations and active Ask AI access.
        All person and conversation identifiers are business-scoped. Only the original
        sender with current contributor access can retry a failed turn. Queued or
        processing turns cannot be retried.
      security:
      - BearerAuth: []
      responses:
        '202':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - active
                        - invited
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  turn:
                    type: object
                    properties:
                      id:
                        type: integer
                      actor_id:
                        type: integer
                      status:
                        type: string
                        enum:
                        - queued
                        - processing
                        - failed
                        - cancelled
                        - private
                      reason:
                        type: string
                        nullable: true
                      can_retry:
                        type: boolean
                    nullable: true
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: turn_id
        in: path
        required: true
        schema:
          type: integer
  "/ask_ai/shared/{id}/export":
    get:
      tags:
      - Ask AI
      summary: Export the current visible message page
      description: Requires read:shared_ai_conversations, active Ask AI access and
        explicit membership in this business. Shared conversations are separate from
        personal AI history and memory. Responses are private and non-cacheable. Downloads
        JSON containing the same permission-filtered projection as the read endpoint.
        This is a bounded page, not an unbounded transcript export.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversation:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      status:
                        type: string
                        enum:
                        - active
                        - invited
                      role:
                        type: string
                        enum:
                        - owner
                        - contributor
                        - viewer
                      updated_at:
                        type: string
                        format: date-time
                      url:
                        type: string
                  messages:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        sequence:
                          type: integer
                        role:
                          type: string
                          enum:
                          - user
                          - assistant
                        content:
                          type: string
                        author_id:
                          type: integer
                        created_at:
                          type: string
                          format: date-time
                        private:
                          type: boolean
                        unavailable:
                          type: boolean
                  participants:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id:
                          type: integer
                        name:
                          type: string
                        role:
                          type: string
                        status:
                          type: string
                        available:
                          type: boolean
                  version:
                    type: string
                  permissions:
                    type: object
                    properties:
                      can_send:
                        type: boolean
                  turns:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        actor_id:
                          type: integer
                        status:
                          type: string
                          enum:
                          - queued
                          - processing
                          - failed
                          - cancelled
                          - private
                        reason:
                          type: string
                          nullable: true
                        can_retry:
                          type: boolean
                  next_before:
                    type: integer
                    nullable: true
                  page_limit:
                    type: integer
                    enum:
                    - 100
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: q
        in: query
        required: false
        schema:
          type: string
          maxLength: 100
      - name: before
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
  "/ask_ai/attachments":
    post:
      tags:
      - Ask AI
      summary: Upload private files for a message
      description: |
        Requires write:chat (or write:own_chat). Up to three files; images/documents
        are limited to 8 MB each. One MP4/MOV/WebM video may be up to 100 MB and
        120 seconds, H.264/HEVC/VP8/VP9, at most 4K. Video processing is asynchronous.
        Poll status_url; do not submit its signed_id until processing.state is ready.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - files
              properties:
                files:
                  type: array
                  maxItems: 3
                  items:
                    type: string
                    format: binary
      responses:
        '200':
          description: Upload accepted; video evidence is still processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  attachments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AskAiAttachment"
        '422':
          description: Unsupported
          corrupt:
          over-limit file or exhausted processing budget:
        '403':
          description: Ask AI access or token scope denied
  "/ask_ai/attachments/{signed_id}":
    parameters:
    - in: path
      name: signed_id
      required: true
      schema:
        type: string
    get:
      tags:
      - Ask AI
      summary: Read private video processing progress
      description: Requires read:chat (or read:own_chat). Owner and tenant are rechecked.
        Response never includes transcript or frame evidence; Cache-Control is no-store.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current processing status
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiAttachmentResponse"
        '404':
          description: Attachment is unavailable to this caller
    patch:
      tags:
      - Ask AI
      summary: Retry interrupted or failed video processing
      description: Requires write:chat (or write:own_chat). Idempotent while queued,
        processing, or ready. Maximum three attempts per clip and six starts per user/hour.
        Clips expire after 24 hours; interrupted work can retry after 10 minutes without
        progress.
      security:
      - BearerAuth: []
      responses:
        '202':
          description: Processing resumed or already active
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiAttachmentResponse"
        '404':
          description: Attachment is unavailable to this caller
        '422':
          description: Expired attachment or processing budget exhausted
    delete:
      tags:
      - Ask AI
      summary: Cancel video processing and remove extracted evidence
      description: Requires write:chat (or write:own_chat). Cancels the current generation
        atomically; an in-flight worker cannot restore cancelled evidence. Original
        private upload expires after 24 hours.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Processing cancelled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiAttachmentResponse"
        '404':
          description: Attachment is unavailable to this caller
  "/feeds/{id}/feature":
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: integer
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Read a post's editorial featuring configuration
      description: |
        Requires read:news_feed_editorial_features and the tenant's editorial
        authority for this post. The employee feed exposes only the currently
        applicable featured badge; it does not expose this editor configuration.

        Since Knowledge Layer Phase 3 (E7) a feature is a Top of News booking on
        the one placement engine. These endpoints keep their request and
        response shapes, but `feature.id` is the booking's (placement's) id — the
        id `POST /feeds/{id}/placements/{placement_id}/end_now` takes.
      responses:
        '200':
          description: Current configuration, or null when withdrawn.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                        description: The Top of News booking's (placement's) id.
                      audience_id:
                        type: integer
                        nullable: true
                      starts_at:
                        type: string
                        format: date-time
                      ends_at:
                        type: string
                        format: date-time
                      position:
                        type: integer
        '403':
          description: Missing scope
          app access or editorial authority.:
        '404':
          description: Post unavailable in this business.
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Set a timed editorial feature for a published post
      description: |
        Requires write:news_feed_editorial_features and editorial authority.
        Replaces the current schedule atomically while preserving history.
        The optional audience narrows the post's existing visibility. This
        placement does not create a personal pin, acknowledgment or notification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - editorial_feature
              properties:
                editorial_feature:
                  type: object
                  required:
                  - starts_at
                  - ends_at
                  - position
                  properties:
                    audience_id:
                      type: integer
                      nullable: true
                    starts_at:
                      type: string
                      format: date-time
                    ends_at:
                      type: string
                      format: date-time
                    position:
                      type: integer
                      minimum: 1
                      maximum: 1000
      responses:
        '200':
          description: Saved feature configuration (`id` is the booking's id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature:
                    type: object
                    properties:
                      id:
                        type: integer
                        description: The Top of News booking's (placement's) id.
                      audience_id:
                        type: integer
                        nullable: true
                      starts_at:
                        type: string
                        format: date-time
                      ends_at:
                        type: string
                        format: date-time
                      position:
                        type: integer
        '403':
          description: Missing scope
          app access or editorial authority.:
        '404':
          description: Post unavailable in this business.
        '422':
          description: Invalid audience
          schedule:
          position or source publication state.:
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Stop featuring a post
      description: Requires write:news_feed_editorial_features and editorial authority.
        History and personal pins are preserved.
      responses:
        '200':
          description: Feature withdrawn
          including an already-withdrawn request.:
        '403':
          description: Missing scope
          app access or editorial authority.:
        '404':
          description: Post unavailable in this business.
  "/feeds/{id}/placements/{placement_id}/dismiss":
    parameters:
    - in: path
      name: id
      required: true
      description: Feed post ID
      schema:
        type: integer
    - in: path
      name: placement_id
      required: true
      description: The placement's `id` (see `CommsPlacement`).
      schema:
        type: integer
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Dismiss one of this post's placements for the caller
      description: |
        The native twin of the web card's Dismiss for a placed POST — Top of
        Home, a store's or department's pin, a work screen. Since Knowledge
        Layer Phase 3 (E7) the one placement engine holds feed posts as well as
        broadcasts. Records the dismissal for the caller only. Only a live
        placement the caller is in the audience of; anything else is 404 (the id
        is guessable and dismissals feed Measure). A broadcast's placement is
        dismissed through `POST /broadcasts/{id}/placements/{placement_id}/dismiss`.

        Asks the `write:news_feed` scope (#6720), before the post is looked up.
      responses:
        '200':
          description: Dismissed
          content:
            application/json:
              schema:
                type: object
                properties:
                  dismissed:
                    type: boolean
                    example: true
                  placement_id:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          description: "`insufficient_permissions` — the token lacks `write:news_feed`."
        '404':
          description: '`{ error: { code: "not_found", message: "Placement not found"
            } }` — no such post or live placement, or the caller is not in its audience.'
  "/feeds/{id}/placements/{placement_id}/end_now":
    parameters:
    - in: path
      name: id
      required: true
      description: Feed post ID
      schema:
        type: integer
    - in: path
      name: placement_id
      required: true
      description: The placement's `id`.
      schema:
        type: integer
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: End one of this post's placements now ("End now")
      description: |
        Takes the placement down for everyone (Knowledge Layer E7): the author,
        or someone the booking rule lets place it (a Communications admin whose
        reach covers its audience; a store manager for their store's pin). On a
        queued place (Top of Home) the booking waiting right behind it starts
        now — `next_placement_id` names it. The broadcast twin is
        `POST /broadcasts/{id}/placements/{placement_id}/end_now`.

        Ending a Top of News booking (`news.top`) un-features the post, so it
        needs `write:news_feed_editorial_features` — the scope
        `DELETE /feeds/{id}/feature` takes. Ending one on any other slot needs
        `write:news_feed` (#6720).
      responses:
        '200':
          description: Ended
          content:
            application/json:
              schema:
                type: object
                description: The answer of an "End now" (`…/placements/{placement_id}/end_now`).
                properties:
                  ended:
                    type: boolean
                    example: true
                  placement_id:
                    type: integer
                  next_placement_id:
                    type: integer
                    nullable: true
                    description: The booking queued behind it that starts now (queued
                      places such as Top of Home only), else null.
                  message:
                    type: string
                    example: Ended — it's off Top of Home now. “Holiday hours” starts
                      there now.
        '401':
          description: Unauthorized
        '403':
          description: "`{ error }` — the caller may not end this placement (the refusal's
            own words), or `insufficient_permissions` when the token lacks the slot's
            scope (above)."
        '404':
          description: '`{ error: { code: "not_found", message: "Placement not found"
            } }` — no such post or current placement.'
        '422':
          description: '`{ error }` — e.g. "It has already ended."'
  "/comments/{id}/report":
    parameters:
    - in: path
      name: id
      required: true
      schema:
        type: integer
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Read your own comment report outcomes
      description: |
        Requires read:news_feed_reports and access to the parent post. Returns
        only the caller's reports, including safe review outcomes after the
        comment is removed. Other reporters and moderator notes are excluded.
      responses:
        '200':
          description: Up to 25 reports, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reports:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        comment_id:
                          type: integer
                        reason:
                          type: string
                        reported_at:
                          type: string
                          format: date-time
                        review_status:
                          type: string
                          enum:
                          - pending_review
                          - approved
                          - rejected
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                        message:
                          type: string
        '403':
          description: Missing scope
          disabled discussion layer or unavailable post.:
        '404':
          description: Comment unavailable in this business.
        '422':
          description: "`not_published` — the post is a draft or scheduled, asked
            by someone preparing it"
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Report a visible comment for moderation
      description: |
        Requires write:news_feed_reports. Reports by the same caller for the
        same reason are idempotent. Reports share a pending moderation case;
        source content stays visible until an authorized moderator decides.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
                  enum:
                  - inappropriate
                  - spam
                  - harassment
                  - misinformation
                  - other
                note:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Report recorded; response includes status and the caller's
            safe report outcome.
        '403':
          description: Missing scope
          disabled discussion layer or unavailable post.:
        '404':
          description: Comment unavailable in this business.
        '422':
          description: Invalid reason or source no longer available; or `not_published`
            — the post is a draft or scheduled, asked by someone preparing it.
  "/ask_ai/saved_answers":
    get:
      tags:
      - Ask AI
      summary: List my saved Ask AI answers
      description: |
        Lists this account's saved answers in the current business, newest saved first.
        Requires `read:chat`, Ask AI app access and Platform Standard or higher.
        Returns at most 30 rows. Pass `next_before` as `before` for the next page;
        a null cursor means there are no more rows. An optional conversation filter
        is applied before pagination and only admits this account's private general threads.

        These are the same account-owned Platform bookmarks shown in My Stuff and
        Personal Agent. Each target is checked again on read. A cleared, missing or
        no-longer-accessible answer returns only `id`, `saved_at` and `available: false`;
        its owner may still remove the bookmark. Transcript text is never copied into
        a bookmark. The normal `_meta` and `unread_notification_count` fields are additive.
      security:
      - BearerAuth: []
      parameters:
      - name: before
        in: query
        schema:
          type: integer
          format: int64
          minimum: 1
        description: Exclusive saved-answer bookmark id from the previous page's next_before.
      - name: conversation_id
        in: query
        schema:
          type: string
        description: Private conversation session id, the id returned by the conversations
          API.
      responses:
        '200':
          description: Saved answers, including unavailable placeholders
          content:
            application/json:
              schema:
                type: object
                required:
                - saved_answers
                - next_before
                properties:
                  saved_answers:
                    type: array
                    maxItems: 30
                    items:
                      "$ref": "#/components/schemas/AskAiSavedAnswer"
                  next_before:
                    type: integer
                    format: int64
                    nullable: true
        '401':
          description: Authentication required
        '402':
          description: Platform Standard required
        '403':
          description: Ask AI unavailable to this user or read scope missing
    post:
      tags:
      - Ask AI
      summary: Save an Ask AI answer for later
      description: |
        Requires `write:chat`, Ask AI app access and Platform Standard or higher.
        Saves the visible assistant answer in this account's own private general
        conversation. Shared, room, editor, hidden and cleared answers are refused.
        Repeating the request returns the existing bookmark without changing the
        answer or the original save time. The normal `_meta` and
        `unread_notification_count` fields are additive.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - conversation_id
              - turn_id
              properties:
                conversation_id:
                  type: string
                  description: Private conversation session id.
                turn_id:
                  type: string
                  description: Durable answer turn id, not the message row id.
      responses:
        '200':
          description: Answer saved, or already saved
          content:
            application/json:
              schema:
                type: object
                required:
                - saved_answer
                properties:
                  saved_answer:
                    "$ref": "#/components/schemas/AskAiSavedAnswer"
        '401':
          description: Authentication required
        '402':
          description: Platform Standard required
        '403':
          description: Ask AI unavailable to this user or write scope missing
        '404':
          description: Answer not found or not visible to this account
  "/ask_ai/saved_answers/{id}":
    delete:
      tags:
      - Ask AI
      summary: Remove a saved Ask AI answer
      description: |
        Requires `write:chat`, Ask AI app access and Platform Standard or higher.
        Removes only this account's Ask AI bookmark in the current business, even
        after its answer was cleared or removed. The answer is unchanged. A bookmark
        with a personal note goes to Trash; a bare bookmark is removed permanently.
        Idempotent: an absent id or another account's id also returns 204.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          format: int64
        description: Saved-answer bookmark id, not a conversation or message id.
      responses:
        '204':
          description: Bookmark no longer saved
        '401':
          description: Authentication required
        '402':
          description: Platform Standard required
        '403':
          description: Ask AI unavailable to this user or write scope missing
  "/ask_ai/messages":
    post:
      tags:
      - Ask AI
      summary: Send message to AI
      description: |
        Send a message to the AI assistant and initiate streaming response.

        **Conversation Restoration:**
        If no `conversation_id` is provided, the API will automatically try to restore
        the user's most recent active conversation. This ensures conversation history
        persists across logout/login cycles without requiring the client to store
        conversation IDs.

        The response includes a `conversation_id` that the client should use to:
        1. Subscribe to the `AiResponseChannel` WebSocket for streaming responses
        2. Reference this conversation in subsequent requests

        **WebSocket Subscription:**
        ```javascript
        const channel = consumer.subscriptions.create(
          { channel: "AiResponseChannel", conversation_id: response.conversation_id },
          {
            received(data) {
              switch(data.type) {
                case 'chunk': // Streaming text chunk
                case 'complete': // Full response with metadata
                case 'error': // Error occurred
                case 'status': // Status update (thinking, generating)
              }
            }
          }
        );
        ```

        Use this endpoint to:
        - Ask questions about schedules, PTO, policies
        - Get help with IT issues
        - Request information from company knowledge base
        - Perform actions like submitting time off requests
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                  description: The user's message to the AI
                  example: How many PTO days do I have left?
                attachment_signed_ids:
                  type: array
                  maxItems: 3
                  items:
                    type: string
                  description: Own uploaded files only; at most one video, which must
                    be ready. A pending, failed, cancelled, or expired video returns
                    409 attachment_not_ready.
                conversation_id:
                  deprecated: true
                  type: string
                  format: uuid
                  description: |
                    **Accepted for backward compatibility but IGNORED.** The server always
                    resolves the thread itself — it continues the caller's most recent
                    active conversation, or mints a new id when there is none. Sending a
                    value here changes nothing except a log line.

                    This is deliberate, and it is a security fix rather than an oversight:
                    the resolved id becomes the key of the WebSocket authorization record
                    that `AiResponseChannel` reads to decide who may subscribe to a stream,
                    so honouring a client-supplied value would let any authenticated user
                    take over another user's stream.

                    Clients MUST read the `conversation_id` returned in the response (and
                    `websocket.subscription.conversation_id`, which is the same value) and
                    subscribe with THAT — never with an id they generated or cached.
                  example: 550e8400-e29b-41d4-a716-446655440000
                mode:
                  deprecated: true
                  type: string
                  description: |
                    **Accepted and validated, but currently has NO effect.** Nothing on the
                    streaming path reads it — `AskAiStreamingJob` forwards `system_context`,
                    `ask_ai_context`, `conversation_history` and `page_context` to the agent
                    pipeline and never `mode` — so every value routes exactly like `general`.
                    An unrecognised value is silently treated as `general` rather than
                    rejected.

                    Kept in the contract because existing native builds send it and it is
                    the seam a future forward would use; do not build client behaviour on
                    the assumption that it changes routing.
                  enum:
                  - general
                  - help
                  - scheduling
                  default: general
                system_context:
                  type: string
                  description: Optional additional context for the AI (hidden from
                    user)
                app_slug:
                  type: string
                  description: |
                    The app the person is in when they ask (a marketplace app slug —
                    a built-in app, an App Builder app or a mini app). The turn is
                    bound to that app's agent, as the web panel does, so "this"
                    and "here" mean that app. Honoured only when the app is enabled,
                    open to this person and has an agent they may use; otherwise it
                    is ignored and the question is routed as usual. The response's
                    `context_app` says which app was honoured.
                  example: tasks
                defer_follow_ups:
                  type: boolean
                  default: false
                  description: |
                    Desktop capability: with surface desktop_app, deliver the answer before
                    optional suggestions. Suggestions arrive in a separate answer_extras
                    cable frame with conversation_id, turn_id and follow_ups, and remain
                    available in message history. Other clients retain complete-frame metadata.
                surface:
                  type: string
                  description: |
                    Which client sent the turn, for per-surface reporting: `mobile_app`
                    (the phone app), `desktop_app` (the desktop app) or
                    `chrome_extension`. An unknown or missing value is recorded as
                    `mobile_app`, except that a request whose User-Agent carries an
                    `Electron/` token is recorded as `desktop_app`.
                  enum:
                  - mobile_app
                  - desktop_app
                  - chrome_extension
                  default: mobile_app
      responses:
        '200':
          description: Message accepted, streaming initiated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MessageResponse"
        '403':
          description: |
            Ask AI is not enabled for this business or not visible to this user
            (`app_disabled`), or the API token lacks the `write:chat` scope
            (`insufficient_permissions`). Full-access and `admin` tokens, and
            session-authenticated callers, are unaffected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Invalid request (missing message)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: AI service unavailable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/conversations":
    get:
      tags:
      - Ask AI
      summary: The caller's threads (the desktop sidebar's list)
      description: |
        Private `general` threads of the caller in this business, pinned first then
        newest activity first (up to 25). `title` is the saved conversation name
        or the first question asked, routing token stripped; it is `null` for
        an unnamed empty thread. `pinned` is the account preference and `active`
        marks the thread the next message lands in. Requires `read:chat`.
        `can_update_conversations` is true only when the caller may rename and
        pin threads (`write:chat`); clients should hide those controls otherwise.

        With `q`, only threads whose saved name contains the term or whose visible
        messages match it (full-text or case-insensitive substring — the same
        matching the per-thread `GET /ask_ai/conversations/{id}/messages?q=`
        uses). The same ordering and cap apply after filtering, so older
        matching or pinned threads remain discoverable.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: History search term, trimmed, at most 100 characters. Blank lists
          every thread.
        schema:
          type: string
          maxLength: 100
      responses:
        '200':
          description: The threads
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  can_update_conversations:
                    type: boolean
                    description: Whether the caller may rename and pin private threads.
                  conversations:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Thread"
    post:
      tags:
      - Ask AI
      summary: New chat
      description: |
        Closes the active thread and opens an empty one, which is returned. An
        active thread that is still empty is reused. Requires `write:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The thread the next message lands in
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ThreadResponse"
  "/ask_ai/conversations/{id}/resume":
    post:
      tags:
      - Ask AI
      summary: Continue an older thread
      description: |
        The thread becomes the active one — the next message lands in it and
        its transcript is the context — and the thread that was active is
        archived (an empty one too, so exactly one thread is ever active). The
        desktop sidebar's one-list model (2026-09-15): any thread is
        resumable, none opens read-only. Resuming the already-active thread is
        a no-op that returns it. Requires `write:chat`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: The thread id from the list (`conversations[].id`).
      responses:
        '200':
          description: The resumed thread, now active
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ThreadResponse"
        '404':
          description: Not one of the caller's threads
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/conversations/{id}":
    patch:
      tags:
      - Ask AI
      summary: Rename or pin a private conversation
      description: |
        Saves the caller's conversation name and/or pinned preference on the
        server, so every client reading the thread list sees the same values.
        Requires `write:chat`. Only the caller's private `general` threads in
        this business can be changed; shared and room conversations return 404.
        At least one of `title` or `pinned` is required. Omitted values stay
        unchanged. Names are trimmed and repeated whitespace is collapsed;
        the normalized name must contain 1–100 characters. `pinned` must be a
        JSON boolean. This operation does not change the active conversation,
        transcript, or `updated_at` activity timestamp.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The thread session id from the conversation list.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                  minLength: 1
                  maxLength: 100
                pinned:
                  type: boolean
      responses:
        '200':
          description: The updated conversation, with standard API metadata.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ThreadResponse"
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: Ask AI is unavailable to the caller or the token lacks write
            scope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: Not one of the caller's private general conversations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: No supported preference, an invalid name, or a non-boolean
            pinned value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    get:
      tags:
      - Ask AI
      summary: Get conversation details
      description: 'Retrieve metadata about a specific conversation including status
        and message count.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Conversation session ID (UUID)
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Conversation details retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  conversation:
                    "$ref": "#/components/schemas/Conversation"
        '404':
          description: Conversation not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Ask AI
      summary: Clear conversation
      description: |
        Clear conversation history. By default, only clears messages.
        Use `clear_everything=true` to also clear learned facts and cache — on the
        person's current thread only. Clearing an earlier thread clears its messages
        alone, leaves it closed, and answers `cleared.facts` and `cleared.cache` false.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Conversation session ID (UUID)
        schema:
          type: string
          format: uuid
      - name: clear_everything
        in: query
        description: If true and this is the current thread, also clears learned facts
          and cache (ignored for an earlier thread)
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Conversation cleared successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Conversation cleared
                  cleared:
                    type: object
                    properties:
                      messages:
                        type: boolean
                      facts:
                        type: boolean
                      cache:
                        type: boolean
        '404':
          description: Conversation not found
  "/ask_ai/conversations/{id}/messages":
    get:
      tags:
      - Ask AI
      summary: Get conversation messages
      description: |
        Retrieve paginated message history for a conversation.
        Messages are returned in chronological order (oldest first).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Conversation session ID (UUID)
        schema:
          type: string
          format: uuid
      - name: page
        in: query
        description: Page number (1-based)
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Messages per page (max 100)
        schema:
          type: integer
          default: 20
          maximum: 100
      - name: q
        in: query
        description: |
          Optional search term (max 200 chars). Restricts the result to messages
          matching it, using the same full-text + substring match the web History
          page uses. `pagination.total_count` and `total_pages` describe the
          FILTERED set, so paging works unchanged while a search is active.
        schema:
          type: string
          maxLength: 200
      responses:
        '200':
          description: Messages retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  conversation_id:
                    type: string
                    format: uuid
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Message"
                  pagination:
                    "$ref": "#/components/schemas/Pagination"
        '404':
          description: Conversation not found
  "/ask_ai/help":
    get:
      tags:
      - Ask AI
      summary: Get AI capabilities and examples
      description: |
        Get list of available AI agents with their capabilities and example questions.
        Use this to show users what they can ask the AI assistant.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Help examples retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  agents:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AgentInfo"
  "/ask_ai/suggestions":
    get:
      tags:
      - Ask AI
      summary: Welcome-screen suggestion chips for the page the client is on
      description: |
        Page-aware starter questions for the chat's empty state, each kept only
        while the app it needs is accessible to this user, plus the agent roster
        the same screen lists under them. Requires `read:chat`. An unknown or
        absent `page_type` returns the default chips. The body is the same as the
        8.x Chrome extension door's `GET /api/v1/extension/ask_ai/suggestions`;
        both answer from `AskAi::Suggestions`.
      security:
      - BearerAuth: []
      parameters:
      - name: page_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - linkedin_profile
          - gmail
          - google_calendar
          - competitor
          - vendor
          - mangoapps
      responses:
        '200':
          description: The chips and the agent roster
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        text:
                          type: string
                          example: Create a task from this email
                        icon:
                          type: string
                          example: check-square
                  agents:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        slug:
                          type: string
                        icon:
                          type: string
                        examples:
                          type: array
                          maxItems: 3
                          items:
                            type: string
  "/ask_ai/cancel":
    post:
      tags:
      - Ask AI
      summary: Cancel active AI request
      description: |
        Cancel an in-progress AI request for a conversation.
        Sends a cancellation signal via WebSocket.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - conversation_id
              properties:
                conversation_id:
                  type: string
                  format: uuid
                  description: The conversation to cancel
                  example: 550e8400-e29b-41d4-a716-446655440000
      responses:
        '200':
          description: Cancellation signal sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Cancellation signal sent
                  conversation_id:
                    type: string
                    format: uuid
        '404':
          description: Conversation not found
        '422':
          description: Invalid request (missing conversation_id)
  "/ask_ai/settings":
    get:
      tags:
      - Ask AI
      summary: Get AI feature settings and current conversation
      description: "Get feature settings for the mobile client to control UI behavior.\n\n**Also
        returns the user's current/latest active conversation** to support\nsession
        restoration after logout/login. Mobile apps should call this endpoint\non
        startup and use the returned `current_conversation.id` to restore the\nuser's
        previous conversation.\n\nReturns the assistant's name (`assistant_name`,
        `assistant_name_custom`) for the Ask AI\nscreen and buttons, and minimal feature
        toggles including:\n- `saved_answers_enabled`: Whether this client may list
        saved Ask AI answers\n- `save_answers_enabled`: Whether this token may save
        or remove an answer (`write:chat`)\n- `create_support_ticket_enabled`: Whether
        Ask AI may file Service Desk tickets for this user\n- `escalation_enabled`:
        Whether to offer \"Get human help\" on an answer whose metadata carries `escalation_offer`
        (POST /ask_ai/escalate)\n- `show_create_ticket_button`: Whether to show the
        standing \"Create Support Ticket\" button (the Service Desk admin's opt-in)\n-
        `function_calls_enabled`: Whether AI can execute actions on behalf of the
        user\n- `schedule_queries_enabled`: Whether scheduling-related queries are
        available\n- `general_questions_enabled`: Whether general Q&A is enabled\n-
        `voice_mode_enabled`: Whether voice mode is available (check before showing
        voice button)\n- `voice_mode_limit_info`: Usage limits for voice mode (daily/session
        limits, remaining minutes)\n\n**Voice Mode Check:**\nAlways check `voice_mode_enabled`
        before showing voice UI. If enabled, \n`voice_mode_limit_info` provides usage
        data to display to users.\n\n**Session Restoration:**\nThe `current_conversation`
        field contains the user's most recent active conversation,\nor `null` if no
        active conversation exists. Use this to restore conversation history\nafter
        the user logs out and logs back in.\n"
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Settings retrieved successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiSettings"
        '401':
          description: Authentication required
  "/ask_ai/voice_token":
    post:
      tags:
      - Ask AI
      - Voice Mode
      summary: Get voice mode token
      description: "Generate an ephemeral token for OpenAI Realtime API voice mode.\n\n**Voice
        Mode Architecture:**\nVoice mode uses a dual-connection approach:\n1. **OpenAI
        WebRTC**: Direct audio streaming between mobile app and OpenAI\n2. **ActionCable
        WebSocket**: Backend queries and response handling\n\n**Flow:**\n1. Call this
        endpoint to get ephemeral token and session configuration\n2. Connect to OpenAI
        via WebRTC using the ephemeral token\n3. Connect to ActionCable and subscribe
        to `VoiceRealtimeChannel`\n4. User speaks → OpenAI transcribes → Send transcript
        to ActionCable\n5. Backend processes query → Response sent via ActionCable\n6.
        Send response text to OpenAI for TTS → Audio plays to user\n\n**Rate Limiting:**\n-
        30 minutes per user per day\n- 15 minutes max per session\n\nSee the [Mobile
        Voice Mode Guide](/api-docs/guides/ask-ai-mobile.md#voice-mode-integration)
        \nfor detailed implementation instructions.\n"
      security:
      - BearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                voice:
                  type: string
                  description: |
                    OpenAI voice to use for TTS. Validated against the full supported set
                    (`OpenaiTtsService::VOICES`); an unrecognised value falls back to the
                    business's configured "AI Voice" setting, or to `marin` when none is set,
                    rather than erroring.
                  enum:
                  - alloy
                  - ash
                  - ballad
                  - coral
                  - echo
                  - fable
                  - nova
                  - onyx
                  - sage
                  - shimmer
                  - verse
                  - marin
                  - cedar
                  default: marin
      responses:
        '200':
          description: Voice token generated successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceTokenResponse"
        '402':
          description: Insufficient voice minutes balance
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceErrorResponse"
        '403':
          description: |
            Voice mode is not enabled for this business (`feature_disabled`), Ask AI is
            not enabled/visible for this user (`app_disabled`), or the API token lacks
            the `write:chat` scope (`insufficient_permissions`). Full-access and `admin`
            tokens, and session-authenticated callers, are unaffected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '429':
          description: |
            Daily voice usage limit reached (`voice_limit_reached`), or the per-user
            hourly mint cap was exceeded (`rate_limited`). `error.details` carries the
            usage meter (`daily_used`, `daily_limit`, `remaining`, `session_limit`) plus
            `open_sessions` / `reserved_minutes` — the minutes already committed by
            sessions this user has not ended, which count toward the daily cap even
            though no duration has been recorded for them yet.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: AI service not configured
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/voice/sessions/{session_id}":
    get:
      tags:
      - Ask AI
      - Voice Mode
      summary: Get voice session status
      description: |
        Check the status of a voice session. Useful for reconnection handling
        when the mobile app needs to verify if a session is still active.
      security:
      - BearerAuth: []
      parameters:
      - name: session_id
        in: path
        required: true
        description: Voice session ID (UUID returned from voice_token endpoint)
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Session status retrieved
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceSessionStatus"
        '404':
          description: Session not found or doesn't belong to current user
  "/ask_ai/voice/sessions/{session_id}/end":
    post:
      tags:
      - Ask AI
      - Voice Mode
      summary: End voice session
      description: |
        Explicitly end a voice session for graceful cleanup and billing finalization.

        **Important:** Always call this endpoint when ending a voice session to ensure
        proper billing and cleanup, even if the WebSocket connection was lost unexpectedly.

        This endpoint:
        1. Calculates final session duration
        2. Charges the appropriate billing amount
        3. Cleans up session data from cache

        If the session was already settled (an earlier End, the ActionCable
        channel, or the abandoned-session sweep), this endpoint returns
        `success: true` with `billed: false` and zeroes — safe to call twice.
        Another user's session, or an unknown id, answers 404.
      security:
      - BearerAuth: []
      parameters:
      - name: session_id
        in: path
        required: true
        description: Voice session ID (UUID returned from voice_token endpoint)
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Session ended successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/VoiceSessionEndResponse"
        '404':
          description: Session not found or doesn't belong to current user
  "/ask_ai/agents":
    get:
      tags:
      - Ask AI
      summary: Agents the caller may @mention
      description: |
        The composer's Agent picker: every agent that is on for this business
        and that this person may use, sorted by name. Hidden and external-portal
        agents are never listed. Start a message with `@<alias>` to send it to
        that agent. Requires `read:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The agent roster
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  agents:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AskAiMentionableAgent"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiReadRefused"
        '500':
          description: The roster could not be loaded (`internal_error`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/pinned_agent":
    get:
      tags:
      - Ask AI
      summary: The agent the conversation is pinned to
      description: |
        The "Continuing with: <Agent>" chip. When an agent's answer asks the
        person a question back, the next turn goes to that agent; the pin is
        released once an agent answers without asking, and lapses 15 minutes
        after it was set. `pinned_agent` is null when nothing is pinned.
        Requires `read:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The pin, or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  pinned_agent:
                    nullable: true
                    allOf:
                    - "$ref": "#/components/schemas/AskAiPinnedAgent"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiReadRefused"
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
    delete:
      tags:
      - Ask AI
      summary: Clear the agent pin
      description: |
        The person dismissed the "Continuing with" chip: the next turn is routed
        as usual. Answers 200 when nothing was pinned too. Requires `write:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The pin is cleared
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiSuccess"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/prompts":
    get:
      tags:
      - Ask AI
      summary: The Prompt Library picker
      description: |
        The system gallery (featured first, then by name) followed by this
        business's own prompts (most recently updated first). Each of the two
        lists is capped at `limit` (200); `truncated` is true when either has
        more. A prompt aimed at an agent this person cannot use is left out.
        Requires `read:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The prompts
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiPromptsResponse"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiReadRefused"
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/prompts/{id}/use":
    post:
      tags:
      - Ask AI
      summary: Use a prompt
      description: |
        The picker's "Use" click. Counts the use and returns what the composer
        needs: the text to put in the draft and the agent to send it to. Nothing
        is sent — sending stays the person's action. Uses of a system gallery
        prompt count at most 30 times per person per hour; past that the call
        still succeeds, the use is just not counted. Requires `write:chat`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The prompt id from `GET /ask_ai/prompts` (digits only).
        schema:
          type: integer
      responses:
        '200':
          description: The prompt to fill the draft with
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  prompt:
                    "$ref": "#/components/schemas/AskAiUsedPrompt"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '404':
          description: Not a prompt this person can see (`not_found`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/memory":
    get:
      tags:
      - Ask AI
      summary: What Ask AI remembers about the caller
      description: |
        The caller's remembered facts in this business (the web "Personal
        Memory" page), 50 per page, most recently used first. Only facts not yet
        forgotten are listed. `memory_enabled` says whether personal memory is
        turned on for this business. Requires `read:chat`.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Case-insensitive match on the fact's text, trimmed, at most 200
          characters.
        schema:
          type: string
          maxLength: 200
      - name: page
        in: query
        required: false
        description: Page number. A value outside 1…`total_pages` is moved to the
          nearest page.
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: The facts
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiMemoryResponse"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiReadRefused"
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
    delete:
      tags:
      - Ask AI
      summary: Forget everything remembered about the caller
      description: |
        Forgets every remembered fact about the caller in this business, and
        stops what was said so far from being learned again. Other businesses
        the person belongs to are untouched. Requires `write:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Forgotten
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  forgotten:
                    type: integer
                    description: How many facts were forgotten.
                    example: 12
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/memory/{id}":
    delete:
      tags:
      - Ask AI
      summary: Forget one remembered fact
      description: |
        Forgets one of the caller's facts in this business. A fact that was
        already forgotten answers 200 as well. Requires `write:chat`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The fact id (`facts[].id`, digits only).
        schema:
          type: integer
      responses:
        '200':
          description: Forgotten
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiSuccess"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '404':
          description: Not one of the caller's facts in this business (`not_found`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/playbook_input":
    post:
      tags:
      - Ask AI
      summary: Fill in the form a paused playbook is waiting on
      description: |
        A playbook step that needs more information pauses and answers with a
        `form_collector` whose `submit_url` is `/ask_ai/playbook_input?input_id=…`.
        Send that `input_id` and the form's values here to continue the run.
        Only the fields the paused step asked for are kept, and only the person
        and business the form was raised for may answer it. Requires `write:chat`.

        `success: true` when the run went on: `text` says what it did next, and
        `ui_response` carries the next card when it paused again. `success:
        false` with `terminal: true` means the form can never be sent again
        (expired, already answered, its run ended or the playbook was turned
        off) — retire its Submit; `text` says which, in the reader's language.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - input_id
              properties:
                input_id:
                  type: string
                  description: The `input_id` from the form's `submit_url`.
                data:
                  type: object
                  additionalProperties: true
                  description: The form's values, keyed by field name.
      responses:
        '200':
          description: The submit was handled (see `success` and `terminal`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  text:
                    type: string
                  ui_response:
                    type: object
                    nullable: true
                  terminal:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found`: no form with that `input_id` is waiting for this
            person.\n"
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: 'The submitted text was blocked by the content policy (`blocked:
            true`).'
  "/ask_ai/confirm_tool":
    post:
      tags:
      - Ask AI
      summary: Approve or decline a pending action
      description: |
        The Confirm / Cancel buttons under an answer that proposed an action
        (an answer whose metadata carries `confirmation_id`). Each confirmation
        runs at most once and only for the person and business that raised it.
        On approve the server checks again that actions are still turned on
        for the organization and that the agent is still available to this
        person; a decline always goes through. Requires `write:chat`.

        When the approved step stops at the next approval in a chain, the
        response carries `requires_confirmation: true` and `confirmation` for
        it — draw new buttons from those.

        Every refusal answers `error.code` `confirmation_failed` with
        `error.details.terminal`: `true` means the card is closed (already
        answered, expired, or not yours) — retire its buttons; `false` means the
        same card can be tried again once the cause clears. When the refusal has
        a more specific cause, `error.details.reason` names it:
        `confirmation_other_surface` (answer it where it was asked),
        `step_up_required`, `capability_not_granted`, `work_context_restricted`
        or `work_context_check_failed`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - confirmation_id
              properties:
                confirmation_id:
                  type: string
                  description: The `confirmation_id` from the answer's metadata.
                approved:
                  type: boolean
                  default: false
                  description: true to run the action. Missing or false declines it
                    — nothing is changed.
                conversation_id:
                  type: string
                  description: |
                    Optional. Used only when the confirmation was stored without
                    its conversation, and only if it is one of the caller's own.
      responses:
        '200':
          description: The confirmation was handled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiConfirmToolResponse"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          description: |
            `confirmation_failed`: the agent is no longer available to this
            person, or the app's work-context access policy now closes it to
            them. Or the request was refused before it reached the action:
            `app_disabled`, `extension_disabled` or `insufficient_permissions`
            (the token lacks `write:chat`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: |
            `confirmation_failed`: the confirmation expired, was already
            answered, belongs to someone else, or is waiting for approval on
            another surface. Always `terminal: true`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            `confirmation_failed`: `confirmation_id` is missing, the stored
            confirmation names no agent that can run it, or action execution is
            turned off for this organization.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '500':
          description: |
            `confirmation_failed`: processing failed. The message says whether
            nothing was changed or the action may already have run.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: |
            `confirmation_failed`: the server could not check that the action
            is still permitted, or could not check the app's work-context
            access policy — try again (`terminal: false`). Or
            `entitlement_unavailable`: the plan check failed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/escalate":
    post:
      tags:
      - Ask AI
      summary: Get human help with an answer
      description: |
        "Get human help" under an answer whose metadata carries
        `escalation_offer` and a `turn_id` (offer it only while
        `settings.escalation_enabled` is true). Files a Service Desk ticket
        with the question, the answer and the turn; when this person has no
        Service Desk, sends the question to the admins of the app that answered
        instead. Calling it again for the same turn returns what the first call
        sent rather than filing again. Requires `write:chat`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - turn_id
              properties:
                turn_id:
                  type: string
                  description: The answer's `metadata.turn_id`.
      responses:
        '200':
          description: The question was handed to a person
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiEscalateResponse"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '422':
          description: |
            `escalation_failed`: nobody is set up to receive help requests for
            this person, the turn is not one of the caller's (or `turn_id` is
            blank), the ticket could not be created, or the app's admins could
            not be reached. `error.message` is the sentence to show.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/answer_feedback":
    post:
      tags:
      - Ask AI
      summary: Rate an answer (thumbs up or down)
      description: |
        Saves an editable rating and optional details on the caller's exact answer.
        Send the answer's turn_id. A supplied unknown turn never falls back to a
        different answer. Legacy conversation_id-only requests are accepted only
        when exactly one answer is identifiable. Saved answers remain rateable
        after diagnostic traces expire. Repeated submissions update the same
        report; switching to up clears the optional details. Staff review is
        separate and is never exposed in conversation history. Requires write:chat.
        The first vote may also update verified knowledge cited by this answer,
        at most once per person per entry per 24 hours.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rating
              properties:
                rating:
                  type: string
                  enum:
                  - up
                  - down
                reason:
                  type: string
                  enum:
                  - incorrect
                  - missing
                  - off_topic
                  - technical
                  - ''
                  description: Optional reason; an empty string clears the reason.
                comment:
                  type: string
                  maxLength: 2000
                  description: Optional details; an empty string clears the comment.
                turn_id:
                  type: string
                  maxLength: 255
                  description: The answer's `metadata.turn_id`.
                conversation_id:
                  type: string
                  maxLength: 255
                  description: Legacy fallback, only when it identifies exactly one
                    answer.
                verified_entry_ids:
                  type: array
                  description: |
                    The answer's `metadata.provenance.verified_entry_ids`. Only the
                    first 20 positive integers are read; ids this exact answer
                    did not cite are ignored. Optional for new clients.
                  items:
                    type: integer
      responses:
        '200':
          description: The rating was accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  recorded:
                    type: boolean
                    description: True when the rating was saved, including an idempotent
                      retry.
                  feedback:
                    type: object
                    properties:
                      rating:
                        type: string
                        enum:
                        - up
                        - down
                      reason:
                        type: string
                      comment:
                        type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '422':
          description: |
            invalid_request: invalid rating, reason or comment, or no answer
            belonging to this caller could be identified unambiguously.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: |
            `service_unavailable`: the rating could not be saved — try again.
            Or `entitlement_unavailable`: the plan check failed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/correct_answer":
    post:
      tags:
      - Ask AI
      summary: Correct an answer
      description: |
        "Correct this answer": saves the person's corrected answer as a
        verified-knowledge entry waiting for an admin's approval — nothing goes
        live until it is approved. HTML is stripped; the question is cut to
        1,000 characters and each answer to 5,000. At most 20 corrections per
        person in any 24 hours. Requires `write:chat`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - question
              - correct_answer
              properties:
                question:
                  type: string
                  description: The question that was asked.
                correct_answer:
                  type: string
                  description: The answer it should have given.
                original_answer:
                  type: string
                  description: The answer that was given, kept for the reviewer.
                conversation_id:
                  type: string
                  description: |
                    The conversation the answer is in, kept for the reviewer. Also
                    finds the answer's trace when `turn_id` is not sent.
                turn_id:
                  type: string
                  maxLength: 255
                  description: The answer's `metadata.turn_id`; marks that answer's
                    trace as corrected.
      responses:
        '200':
          description: The correction was sent for review
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    description: The sentence to show; an admin is told where to approve
                      it.
                    example: Thanks — your correction was sent to your admins for
                      review.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '422':
          description: "`correction_failed`: the question or corrected answer is blank,
            or the entry could not be saved."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '429':
          description: "`correction_failed`: the daily correction limit (20 in 24
            hours) is reached."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          "$ref": "#/components/responses/AskAiEntitlementUnavailable"
  "/ask_ai/composer_assist":
    post:
      tags:
      - Ask AI
      summary: Preview a clearer or corrected message draft
      description: |
        Privately transforms the caller's chat message draft and returns it to
        the composer. Nothing is posted, stored or logged. `mode` picks the
        transformation:

        - `clarify` — turn rough thoughts into clear wording, preserving uncertainty and intent
        - `proofread` — minimal spelling, grammar and punctuation corrections; draft only
        - `rewrite` — clearer, with grammar and spelling fixed, same meaning and tone
        - `professional` — a polished workplace tone
        - `friendly` — a warm, approachable tone
        - `shorten` — much more concise, same key facts

        The draft keeps its language, @mentions, links, names, dates and
        numbers. Needs the Chat setting `ask_ai_composer_assist_enabled`.
        30 calls per person per hour. Requires `write:chat`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              - mode
              properties:
                text:
                  type: string
                  maxLength: 4000
                  description: The draft. Must not be blank.
                mode:
                  type: string
                  enum:
                  - rewrite
                  - clarify
                  - proofread
                  - professional
                  - friendly
                  - shorten
                format:
                  type: string
                  enum:
                  - chat_body
                  description: Optional canonical chat text; preserves mention, link
                    and code tokens.
                context:
                  type: object
                  description: Optional for clarify only; requires ask_ai_enabled.
                    Loaded and authorized on the server.
                  required:
                  - scope
                  - room_id
                  - target_message_id
                  properties:
                    scope:
                      type: string
                      enum:
                      - room
                      - thread
                    room_id:
                      type: integer
                    target_message_id:
                      type: integer
                    thread_root_id:
                      type: integer
                      description: Required for thread scope; the canonical root.
      responses:
        '200':
          description: The transformed draft
          content:
            application/json:
              schema:
                type: object
                properties:
                  text:
                    type: string
                    description: The transformed draft. The response has no `success`
                      key.
                  outcome:
                    type: string
                    enum:
                    - changed
                    - unchanged
                    - needs_detail
                    description: Present for clarify and proofread. The original draft
                      is returned for unchanged or needs_detail.
                  hint:
                    type: string
                    description: A short prompt to add missing intent when needs_detail.
        '404':
          description: Context is deleted, inaccessible or outside the room.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          description: |
            `feature_disabled`: composer assist is off for this business. Or
            `app_disabled`, `extension_disabled`, or `insufficient_permissions`
            (the token lacks `write:chat`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            `invalid_request` (blank `text`), `text_too_long` (over 4,000
            characters) or `invalid_mode`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '429':
          description: |
            `budget_exhausted`: the organization's monthly AI budget is used up
            until the 1st. Or `rate_limited`: the hourly limit of 30 is reached.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: |
            `service_unavailable`: the AI service did not return a draft — the
            call does not count toward the hourly limit. Or
            `entitlement_unavailable`: the plan check failed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/ask_ai/cable_token":
    post:
      tags:
      - Ask AI
      summary: Get a short-lived WebSocket credential
      description: |
        Issues a short-lived ActionCable credential for a client with no
        browser cookie (the desktop app), so it never puts its own session or
        API token in the WebSocket URL. Connect with
        `<websocket.url>?cable_token=<cable_token>` and subscribe to
        `AiResponseChannel`; the credential opens no other channel. It lasts
        `expires_in` seconds (one hour) and can be reused within that time so
        reconnects work — ask for a new one after it expires. Each connection
        checks again that the account is active and still belongs to this
        business. 60 per person per hour. Requires `write:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The credential
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AskAiCableTokenResponse"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          "$ref": "#/components/responses/AskAiPaymentRequired"
        '403':
          "$ref": "#/components/responses/AskAiWriteRefused"
        '429':
          description: "`rate_limited`: 60 credentials an hour already issued to this
            person."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: |
            `service_unavailable`: the credential could not be stored — try
            again. Or `entitlement_unavailable`: the plan check failed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/moment_shares":
    get:
      tags:
      - Shared Moments
      summary: My moments shared outside the company
      description: |
        The caller's own pages shared outside the company and not stopped, newest
        first. Only the kinds whose app the token may read are listed
        (read:recognition for recognitions, awards, work anniversaries and a year
        in recognition; read:contests for contest wins; read:training for
        certificates).

        `page_open` says whether the page and its card answer now: a share whose
        recognition was made private, whose app was turned off, or whose company
        turned the Shared moments switch off (`meta.sharing_enabled`) reads
        "no longer shared" until that changes; its `public_url` and
        `card_image_url` are then null.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The caller's open shares
          content:
            application/json:
              schema:
                type: object
                required:
                - moment_shares
                - meta
                properties:
                  moment_shares:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MomentShare"
                  meta:
                    type: object
                    properties:
                      sharing_enabled:
                        type: boolean
                        description: The company's Shared moments switch. While it
                          is off, nothing is public.
                        example: true
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 25
                      total_count:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
        '401':
          description: Missing or invalid token
        '403':
          description: The token holds none of read:recognition, read:contests or
            read:training (`insufficient_permissions`)
    post:
      tags:
      - Shared Moments
      summary: Share one of my moments outside the company
      description: |
        Opens the moment's public page and answers its link, its card picture and
        a LinkedIn share link — the web Share panel's Share to LinkedIn / Copy link.
        Sharing again after a stop reopens the same link.

        Only the person the moment is about may share it, while it is public and
        live and the company's Shared moments switch is on; anyone else, a private
        or revoked moment, or the switch off answers 422 `not_shareable` (the
        payloads that draw a moment carry the same answer as `can_share_outside`).
        Refused under an impersonation token. The token needs the moment's app's
        write scope: write:recognition, write:contests or write:training.

        What leaves the company is the card: the person's name, what they earned
        and the company — never the message, the giver or points. A certificate's
        link is its verification page.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subject_type
              properties:
                subject_type:
                  type: string
                  description: The kind of record. `anniversary` is an award (a work
                    anniversary); the web Share link's names (`recognition_post`,
                    `contest_award`, `training_certificate`, `year`) are accepted
                    too.
                  enum:
                  - recognition
                  - award
                  - anniversary
                  - contest_win
                  - certificate
                  - year_in_recognition
                  example: award
                subject_id:
                  type: integer
                  description: The record's id (a recognition post, award, contest
                    award or training certificate). For `year_in_recognition`, the
                    year when `year` is not sent.
                  example: 4812
                year:
                  type: integer
                  description: For `year_in_recognition` — the year (one of `GET /recognitions/wrapped`'s
                    `meta.available_years`; another year is a 404). Omitted, the current
                    year.
                  example: 2026
      responses:
        '201':
          description: Shared
          content:
            application/json:
              schema:
                type: object
                required:
                - moment_share
                properties:
                  moment_share:
                    "$ref": "#/components/schemas/MomentShare"
        '400':
          description: Unknown `subject_type` (`invalid_parameter`)
        '401':
          description: Missing or invalid token
        '403':
          description: The token lacks the moment's app's write scope (`insufficient_permissions`)
        '404':
          description: No such record in this company (or a year the company has no
            recap for)
        '422':
          description: The caller may not share this moment now (`not_shareable`)
  "/moment_shares/{id}":
    delete:
      tags:
      - Shared Moments
      summary: Stop sharing a moment
      description: |
        Stops the caller's own share: its public page reads "no longer shared"
        (404) and its card stops showing on that page. Always allowed for the
        person's own share — even after the moment was revoked or the switch
        turned off. Another person's share is a 404. The token needs the moment's
        app's write scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Stopped
          content:
            application/json:
              schema:
                type: object
                required:
                - moment_share
                properties:
                  moment_share:
                    "$ref": "#/components/schemas/MomentShare"
        '401':
          description: Missing or invalid token
        '403':
          description: The token lacks the moment's app's write scope (`insufficient_permissions`)
        '404':
          description: Not the caller's share
  "/shares":
    get:
      tags:
      - Share
      summary: Where a record can be sent, and where it already went
      description: |
        The places that send to one colleague ("Message a colleague…",
        "Email a colleague…", "Text a colleague…"), each with the `token` to pass
        as `place` to GET /shares/people and as `target` to POST /shares, and the
        record's live shares the caller may take back (at most 10).
        `channels_available` says whether any room or channel can be searched
        (GET /shares/channels).

        **Token scope:** `write:shares`. 404 `not_found` for a record the caller
        cannot open; 403 `app_not_available` when its app is not open to them.
      security:
      - BearerAuth: []
      parameters:
      - name: shareable_type
        in: query
        required: true
        description: The record's type.
        schema:
          type: string
          enum:
          - Wiki
          - SitePage
          - TrainingCourse
          - TrainingLesson
          - HelpRequest
          - HelpRequestComment
          - Crm::Account
          - Crm::Activity
          - WorkspaceMessage
          - RecognitionPost
          - Award
          - Contests::Award
          - MaForms::FormTemplate
          example: Wiki
      - name: shareable_id
        in: query
        required: true
        schema:
          type: integer
          example: 42
      responses:
        '200':
          description: The colleague places and the shares so far.
          content:
            application/json:
              schema:
                type: object
                properties:
                  people_places:
                    type: array
                    items:
                      type: object
                      properties:
                        token:
                          type: string
                          description: Opaque; pass back as-is.
                        kind:
                          type: string
                          enum:
                          - chat
                          - email
                          - text
                        label:
                          type: string
                          example: Email a colleague…
                        note:
                          type: string
                          nullable: true
                          description: 'Text only: what a text costs, as the web drawer
                            says before the send.'
                        unavailable_reason:
                          type: string
                          nullable: true
                          description: 'Text only: why no colleague can be texted
                            this month (the budget or the credits are spent); GET
                            /shares/people then offers nobody.'
                  channels_available:
                    type: boolean
                  nowhere_hint:
                    type: string
                    nullable: true
                    description: 'When no place at all is switched on: who can switch
                      one on, in the reader''s words.'
                  can_suggest_note:
                    type: boolean
                    description: Offer "Suggest a line" beside the note box (POST
                      /shares/suggest_note) — false when this deployment has no AI
                      or the record is staff-only, where the web panel shows no button.
                  shares:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 9
                        status:
                          type: string
                          description: "`sent`, or `removed` once taken back."
                          example: sent
                        place:
                          type: string
                          description: Where it went — the room
                          channel or colleague's name.:
                          example: "#store-managers"
                        destination_kind:
                          type: string
                          description: 'The system it went to: chat, messaging (Teams/Slack),
                            hub, email, text.'
                          example: chat
                        note:
                          type: string
                          nullable: true
                          description: The sender's note.
                        created_at:
                          type: string
                          format: date-time
                        can_remove:
                          type: boolean
                          description: 'The caller may take it back (DELETE /shares/{id}):
                            its sender or an admin, with a `destructive:shares` token.'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` (no `write:shares`), `app_not_available`,
            or `forbidden` for a proud moment asked under View-as or an impersonation
            token (only the person posts their own moment)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such record, or the caller cannot open it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    post:
      tags:
      - Share
      summary: Share a record
      description: |
        Sends the record's title, the caller's note and its link to one place.
        One press at a time per record and place: a second press while the first
        is still going answers 409 `in_progress`, and the same record to the same
        place under the same title within ten minutes answers 409 `duplicate`
        (with the earlier `share` when the caller may remove it).

        `target` is a channel's `token` (GET /shares/channels), or a person
        place's `token` (GET /shares) together with `recipient_id` (GET /shares/people).

        **Token scope:** `write:shares`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shareable_type
              - shareable_id
              - target
              properties:
                shareable_type:
                  type: string
                  example: Wiki
                shareable_id:
                  type: integer
                  example: 42
                target:
                  type: string
                  description: A channel token or a person place token.
                recipient_id:
                  type: integer
                  description: The colleague
                  for a person place.:
                note:
                  type: string
                  maxLength: 500
                  description: Optional; travels with the link.
      responses:
        '201':
          description: Shared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  share:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 9
                      status:
                        type: string
                        description: "`sent`, or `removed` once taken back."
                        example: sent
                      place:
                        type: string
                        description: Where it went — the room
                        channel or colleague's name.:
                        example: "#store-managers"
                      destination_kind:
                        type: string
                        description: 'The system it went to: chat, messaging (Teams/Slack),
                          hub, email, text.'
                        example: chat
                      note:
                        type: string
                        nullable: true
                        description: The sender's note.
                      created_at:
                        type: string
                        format: date-time
                      can_remove:
                        type: boolean
                        description: 'The caller may take it back (DELETE /shares/{id}):
                          its sender or an admin, with a `destructive:shares` token.'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions`, `app_not_available`, or `forbidden`
            for a proud moment asked under View-as or an impersonation token."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such record, or the caller cannot open it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`duplicate` (already sent there; `details.share` when the
            caller may remove it) or `in_progress`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`destination_unavailable` (the place is gone, or this token's
            chat scopes do not allow it), `target_required` (pick a place, or the
            colleague for a person place), `note_too_long`, `invalid`, or the place
            turning the colleague or room away in its own words: `chat_refused`, `email_refused`,
            `text_refused`, `hub_refused`, `not_a_member`, `unknown_target`, `unsupported`
            — the sender's to fix; retrying does not help."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '502':
          description: "`delivery_failed` or `delivery_unknown` — the place did not
            answer (an outage), or answered with an error; the message says what to
            do, and a later retry may go through."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shares/suggest_note":
    post:
      tags:
      - Share
      summary: Suggest a line for the note box
      description: |
        One suggested line for the note sent with a share — the web Share
        panel's "Suggest" — drafted from facts the recipients can already see
        (the record's kind, title and age, never its body). It sends nothing:
        the person edits, keeps or deletes it, then POST /shares sends it as
        `note`. Nothing is drafted for a staff-only record. Offer it only when
        GET /shares answers `can_suggest_note: true`.

        **Token scope:** `write:shares`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - shareable_type
              - shareable_id
              properties:
                shareable_type:
                  type: string
                  example: TrainingLesson
                shareable_id:
                  type: integer
                  example: 42
                target_name:
                  type: string
                  maxLength: 200
                  description: The place's name, to address the line to (optional).
      responses:
        '200':
          description: The suggested line.
          content:
            application/json:
              schema:
                type: object
                properties:
                  note:
                    type: string
                    maxLength: 220
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` (no `write:shares`), `app_not_available`,
            or `forbidden` for a proud moment asked under View-as or an impersonation
            token."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such record, or the caller cannot open it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`staff_only` (nothing to draft from), `ai_blocked` (the organization's
            AI is paused or its budget spent — the message says which), or `no_suggestion`
            (none came back; write one)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: "`ai_unavailable` — no AI on this deployment."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shares/channels":
    get:
      tags:
      - Share
      summary: Rooms and channels the record can be posted in
      description: |
        The caller's chat rooms and channels, the Teams and Slack channels their
        company connected with sharing on, and the Hub groups they belong to,
        whose name holds `q` (their most recent ones for a blank `q`), up to ten
        per system — only the rooms this token's chat scopes let it post in.
        `shared_at` says when the record already went there.

        **Token scope:** `write:shares`.
      security:
      - BearerAuth: []
      parameters:
      - name: shareable_type
        in: query
        required: true
        schema:
          type: string
          example: Wiki
      - name: shareable_id
        in: query
        required: true
        schema:
          type: integer
          example: 42
      - name: q
        in: query
        required: false
        schema:
          type: string
          maxLength: 100
      responses:
        '200':
          description: The matching rooms and channels.
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    type: array
                    items:
                      type: object
                      properties:
                        token:
                          type: string
                          description: Pass as `target` to POST /shares.
                        name:
                          type: string
                          example: store-managers
                        place:
                          type: string
                          example: OneMango chat
                        shared_at:
                          type: string
                          format: date-time
                          nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions`, `app_not_available`, or `forbidden`
            for a proud moment asked under View-as or an impersonation token."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such record, or the caller cannot open it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shares/people":
    get:
      tags:
      - Share
      summary: Colleagues a person place can send the record to
      description: |
        Active colleagues the chosen place can reach (Email: an email address;
        Text: a verified mobile it may text; chat: anyone but the caller and
        anyone an information barrier keeps them from messaging), who can open
        the record, whose name holds `q`. 20 per page.

        **Token scope:** `write:shares`.
      security:
      - BearerAuth: []
      parameters:
      - name: shareable_type
        in: query
        required: true
        schema:
          type: string
          example: Wiki
      - name: shareable_id
        in: query
        required: true
        schema:
          type: integer
          example: 42
      - name: place
        in: query
        required: true
        description: A `people_places[].token` from GET /shares.
        schema:
          type: string
      - name: q
        in: query
        required: false
        schema:
          type: string
          maxLength: 100
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: One page of colleagues.
          content:
            application/json:
              schema:
                type: object
                properties:
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        avatar_url:
                          type: string
                          nullable: true
                  page:
                    type: integer
                  more:
                    type: boolean
                  unavailable_reason:
                    type: string
                    description: 'Text only, and only when no colleague can be texted
                      this month: why (people is then empty).'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions`, `app_not_available`, or `forbidden`
            for a proud moment asked under View-as or an impersonation token."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such record, or the caller cannot open it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`invalid_place` — `place` is not a person place from GET /shares."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/shares/{id}":
    delete:
      tags:
      - Share
      summary: Take a share back
      description: |
        Removes the post from the room or channel where the place allows it, and
        takes the share off the record. `remote_removed: false` means it was
        removed here but may still be in the place — `note` says what to do there.
        A share already removed answers 200 with `already_removed: true`, and
        with the `share` itself only for its sender or an admin.

        **Token scope:** `destructive:shares` (minted on sign-in; not in the
        employee's own list of scopes for a token made by hand, because an
        admin's Remove reaches other people's shares). The sender or an admin may remove it; anyone who
        cannot open the record gets 404, as for a share that does not exist.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  share:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 9
                      status:
                        type: string
                        description: "`sent`, or `removed` once taken back."
                        example: sent
                      place:
                        type: string
                        description: Where it went — the room
                        channel or colleague's name.:
                        example: "#store-managers"
                      destination_kind:
                        type: string
                        description: 'The system it went to: chat, messaging (Teams/Slack),
                          hub, email, text.'
                        example: chat
                      note:
                        type: string
                        nullable: true
                        description: The sender's note.
                      created_at:
                        type: string
                        format: date-time
                      can_remove:
                        type: boolean
                        description: 'The caller may take it back (DELETE /shares/{id}):
                          its sender or an admin, with a `destructive:shares` token.'
                    description: Absent when an already-removed share is asked about
                      by someone who is neither its sender nor an admin.
                  remote_removed:
                    type: boolean
                  note:
                    type: string
                    nullable: true
                  already_removed:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions`, or `forbidden` (only its sender
            or an admin may remove it)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`never_sent` — the share failed, so there is nothing to remove."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '502':
          description: "`remove_failed` — the place did not answer; try again."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/my_team/outstanding":
    get:
      tags:
      - My Team
      summary: What your team still owes
      description: |
        The /my_team Outstanding tab as JSON: what everyone below the caller on
        their reporting line still owes — training, policy acknowledgments,
        Must-Reads, onboarding tasks and the other kinds the tenant tracks —
        rolled up per direct report, with the people who owe the most listed
        and their items.

        Rooted at the caller over their whole reporting line in this business;
        nothing is tenant-wide. `remind` on each item says whether the caller
        could send that item's reminder.

        **`by_direct_report` is one page.** A company leader can have thousands
        of direct reports, so the lines come a page at a time (25, at most 100),
        the most urgent line first, then the line with the most items, then by
        name; `by_direct_report_meta` counts every line before the page is cut.

        Where a works council hides names, reads and confirmations of the people
        it covers are counted, not named (`names_hidden`).
      security:
      - BearerAuth: []
      parameters:
      - name: kind
        in: query
        required: false
        description: One kind (a value `kinds[].key` lists). An unknown kind is a
          422.
        schema:
          type: string
      - name: person
        in: query
        required: false
        description: One team member's whole list. Someone off the caller's team is
          a 404.
        schema:
          type: integer
      - name: people_limit
        in: query
        required: false
        description: How many people to list (default 25, at most 100).
        schema:
          type: integer
      - name: items_per_person
        in: query
        required: false
        description: How many items each listed person carries (default 5, at most
          50).
        schema:
          type: integer
      - name: by_direct_report_page
        in: query
        required: false
        description: The page of `by_direct_report` lines (default 1).
        schema:
          type: integer
      - name: by_direct_report_per_page
        in: query
        required: false
        description: Lines per page (default 25, at most 100).
        schema:
          type: integer
      responses:
        '200':
          description: The team's outstanding items
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  outstanding:
                    type: object
                    properties:
                      total:
                        type: integer
                        description: Open items across the team
                      urgent:
                        type: integer
                      people_count:
                        type: integer
                        description: People who owe something
                      kinds:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                            label:
                              type: string
                            total:
                              type: integer
                            urgent:
                              type: integer
                            app_slug:
                              type: string
                              nullable: true
                      by_direct_report:
                        type: array
                        description: One page of lines — a direct report (or, behind
                          one who left, their reports) and everyone below them.
                        items:
                          type: object
                          properties:
                            user_id:
                              type: integer
                            name:
                              type: string
                            own:
                              type: integer
                              description: What they owe themselves
                            below:
                              type: integer
                              description: What their own team owes
                            total:
                              type: integer
                            urgent:
                              type: integer
                            leads_team:
                              type: boolean
                            people_with_items:
                              type: integer
                            by_kind:
                              type: object
                              additionalProperties:
                                type: integer
                            names_hidden:
                              type: boolean
                              description: Someone on this line has their reads and
                                confirmations left out (works council)
                      by_direct_report_count:
                        type: integer
                        description: Every line, counted before the page is cut
                      by_direct_report_truncated:
                        type: boolean
                        description: More lines exist than this page lists
                      by_direct_report_meta:
                        type: object
                        properties:
                          total_count:
                            type: integer
                          total_pages:
                            type: integer
                          current_page:
                            type: integer
                          per_page:
                            type: integer
                          has_next_page:
                            type: boolean
                          has_prev_page:
                            type: boolean
                      people:
                        type: array
                        items:
                          type: object
                          properties:
                            user_id:
                              type: integer
                            name:
                              type: string
                            total:
                              type: integer
                            urgent:
                              type: integer
                            direct_report_id:
                              type: integer
                              nullable: true
                            by_kind:
                              type: object
                              additionalProperties:
                                type: integer
                            names_hidden:
                              type: boolean
                            items:
                              type: array
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                  kind:
                                    type: string
                                  kind_label:
                                    type: string
                                  type:
                                    type: string
                                  title:
                                    type: string
                                  priority:
                                    type: string
                                  due_at:
                                    type: string
                                    format: date-time
                                    nullable: true
                                  overdue:
                                    type: boolean
                                  url:
                                    type: string
                                    nullable: true
                                  remind:
                                    type: object
                                    properties:
                                      available:
                                        type: boolean
                                      label:
                                        type: string
                                      everyone:
                                        type: boolean
                      people_truncated:
                        type: boolean
                      names_hidden:
                        type: object
                        description: Present when a works council keeps some reads
                          and confirmations from being named.
                        properties:
                          count:
                            type: integer
                            nullable: true
                          group_count:
                            type: integer
                            nullable: true
                          fewer_than:
                            type: integer
                            description: 'Present only when the people it leaves out
                              (or the hidden group) are fewer than the company''s
                              smallest group: that number — they are "a few", and
                              `count` / `group_count` are null (#4546).'
                          reason:
                            type: string
                          kinds:
                            type: array
                            items:
                              type: string
                      kind:
                        type: string
                        nullable: true
                      person_id:
                        type: integer
                        nullable: true
        '403':
          description: No business context, or the token lacks read:people_directory
        '404':
          description: The caller has nobody reporting to them here, or the person
            is not on their team
        '422':
          description: Unknown kind
  "/notifications":
    get:
      tags:
      - Notifications
      summary: List notifications
      description: |
        Retrieve notifications for the current user. Returns paginated notifications
        scoped to the current business.

        Use this endpoint to:
        - List all active notifications
        - Filter by read/unread/archived status
        - Filter to ONE activity type (Wikis, Announcements, Messages, …)
        - Paginate through notification history

        **`filter` carries two vocabularies.**

        *Read state* — the values that shipped first, unchanged:

        - `unread` — only unread notifications
        - `read` — only read (non-archived) notifications
        - `archived` — only archived notifications
        - `all` (or omit) — all active (non-archived) notifications

        *Activity subject* — one activity type. The value is a subject `key`
        exactly as `GET /notifications/home` hands it out on any Activity
        section or Needs-you entry (`wikis`, `system_announcements`,
        `broadcast_messages`, `direct_messages`, `training`, `forms`,
        `news_feed_notifications`, `kind:dm`, `kind:broadcast`, …). This is what
        the Activity group's **See all →** opens, and the same key the web
        "See all wikis" link puts in `?subject=`.

        **Every registered notification category is a valid value**, plus the
        four `kind:` fallback buckets (`kind:broadcast`, `kind:agent`,
        `kind:dm`, `kind:system`) that an uncategorised notification lands in —
        not merely the subjects that happen to have rows right now. The set is
        derived from the category registry, so it grows on its own as apps are
        added and never needs a client release.

        Pass back the key you were given rather than hardcoding a list. The
        subjects one user sees are a small slice of the whole set — a user with
        no schedule notifications never sees `schedule_management` on their own
        home screen, while most of their colleagues do — so a hardcoded list
        built from one account's inbox will be wrong for everyone else.

        A valid key you have no notifications in returns an empty `200`, not an
        error. Matching is case- and separator-insensitive **over the key**:
        `system_announcements`, `system-announcements` and
        `System Announcements` are one value. It does not match display
        **labels** — the label of `news_feed_notifications` is "News Feed", and
        `filter=News Feed` is a `400` whose message points at the real key. Pass
        back the `key`, not the `label`.

        Read states (`unread`, `read`, `archived`, `all`) are matched the same
        way, so `filter=Unread` and `filter=unread` are one value too.

        A subject filter lists that subject across the **visible inbox** —
        snoozed, expired and archived rows are excluded, the same visibility
        `GET /notifications/home` counts under.

        It covers **both zones**: a subject can hold open asks as well as
        activity, and the key you pass back may have come from either section, so
        the list holds that subject's `activity.sections[].count` **plus** its
        `needs_you.entries[].count`. Expect a list longer than the Activity chip
        alone whenever the subject also has something waiting on the user; the
        rows carry `action_required` / `action_completed_at`, so a client that
        wants one zone can split them itself.

        Add `folder=` to narrow it further (`folder=unread&filter=wikis`)
        or `q=` to search within it.

        `subject=<key>` is the older spelling of the same narrowing and still
        works. Passing both is fine when they agree; passing two DIFFERENT
        subjects is a `400 conflicting_subject_filter` rather than a silently
        dropped filter.

        A `filter` value that is neither a read state nor a known subject key is
        a `400 invalid_filter` — the endpoint will not answer "wikis" with every
        notification you have. The error message suggests near-miss keys.

        **Direct-message rows.** "New mail from …" and DM-mention rows carry up
        to 160 characters of the message, so they are served only where the
        Messages app's own API would serve them: the app is enabled for the
        business AND the token may read messages (`read:messages`). Any other
        token sees its notifications without those rows — here, in
        `unread_count`, the piggyback `unread_notification_count`, the home
        zones and every write (a hidden row is "not found") — rather than a 403
        for the whole inbox.

        **Ordering.** Every folder is returned in the same order the web Inbox
        uses, so a native client can render the list as-is and match what the
        user sees on the web. There are two rules.

        *Archived* is ordered by `archived_at` descending — most recently
        ARCHIVED first, nulls last. The archive is a filing cabinet, so the item
        the user just archived is on top regardless of how old the underlying
        event is. Ties fall back to `created_at` then `id`, both descending (a
        bulk archive stamps one identical `archived_at` across the whole
        selection, so the tiebreak is routine).

        *Every other folder* is ordered by triage:

        1. open action requests first (`action_required` and not yet completed)
        2. then `priority`, high to low
        3. then `created_at`, newest first
        4. then `id` descending, so paging can't repeat or skip a row that ties
           another on `created_at`

        Clients should render in the order received rather than re-sorting.
        `created_at` is only a tiebreaker in both rules, and sorting the archive
        by it reproduces the bug this ordering exists to fix. Unarchiving does
        not reorder anything: `archived_at` is cleared and the item returns to
        its ranked position in the active list.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        description: 'Read state (`unread`, `read`, `archived`, `all`) OR an activity
          subject key from `GET /notifications/home`. Not an enum: the subject half
          is derived from the tenant''s registered notification categories and grows
          with the apps installed. See the endpoint description.'
        schema:
          type: string
        examples:
          unread:
            summary: Read state — unread only
            value: unread
          wikis:
            summary: Activity type — Wikis
            value: wikis
          announcements:
            summary: Activity type — System Announcements
            value: system_announcements
          messages:
            summary: Activity type — Direct Messages
            value: direct_messages
      - name: subject
        in: query
        description: Narrow to one activity subject — the older spelling of `filter=<key>`,
          and what the web "See all wikis" link uses. Unlike `filter`, an unrecognised
          key returns an empty list rather than an error.
        schema:
          type: string
      - name: folder
        in: query
        description: Inbox folder to list. Composes with `filter=<subject>` / `subject=`.
          `messages` is accepted but served by the chat API, so it returns an empty
          list here — read mail through the chat endpoints. `mail-alerts` is the folder
          that lists the notifications ABOUT mail.
        schema:
          type: string
          enum:
          - inbox
          - unread
          - read
          - action
          - broadcasts
          - announcements
          - system
          - agents
          - snoozed
          - archived
          - mail-alerts
          - messages
      - name: q
        in: query
        description: Free-text search over notification title and content.
        schema:
          type: string
      - name: page
        in: query
        description: Page number (1-indexed)
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Items per page (max 100)
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Paginated list of notifications
          content:
            application/json:
              schema:
                type: object
                required:
                - notifications
                - meta
                properties:
                  notifications:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Notification"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
        '400':
          description: "`invalid_filter` — the `filter` value is neither a read state
            nor a known activity subject key. Or `conflicting_subject_filter` — `filter`
            and `subject` named two different subjects."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/notifications/{id}":
    get:
      tags:
      - Notifications
      summary: Get a notification
      description: |
        Retrieve a single notification by ID. The notification must belong to the
        current user and current business.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification details
          content:
            application/json:
              schema:
                type: object
                required:
                - notification
                properties:
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: notification_not_found
                      message:
                        type: string
                        example: Notification not found
  "/notifications/unread_count":
    get:
      tags:
      - Notifications
      summary: Get unread notification count
      description: |
        Returns the count of unread notifications for the current user
        within the current business. Useful for badge counts on mobile.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unread count
          content:
            application/json:
              schema:
                type: object
                required:
                - count
                properties:
                  count:
                    type: integer
                    description: Number of unread notifications
                    example: 5
        '401':
          description: Unauthorized
  "/notifications/home":
    get:
      tags:
      - Notifications
      summary: Notifications home screen (Needs you + Activity)
      description: |
        Everything needed to draw the notifications front door — the screen behind
        the bell icon — in ONE round-trip, in the same two sections and the same
        order the web `/inbox` and `/m/inbox` render. Built from the same
        `Inbox::SubjectGrouping` the web uses, so a notification cannot land in a
        different group on mobile than it does on the desktop.

        Both sections group by **subject** — what an item is ABOUT ("time-off
        requests", "the ticket I filed") — rather than by how it was produced.
        Subjects come from the categories the user's own notification-preferences
        page already names, so a group heading always has a preference toggle
        behind it.

        ### Needs you

        Open asks waiting on this user: `action_required` and not yet completed.
        This is the zone whose count the bell badge reports and the only zone a
        user can empty by doing the work.

        Ordered **urgent first, then longest waiting first** (`ordering:
        "longest_waiting_first"`). Age alone would bury a brand-new urgent ask
        under a week-old routine one; priority alone lets an ask rot quietly.
        Render entries in the order received.

        Each entry carries the four facts the web row shows:

        | field | renders as |
        |---|---|
        | `count` | the count pill |
        | `unread_count` | the "N new" pill (omit the pill when 0) |
        | `high_priority` | the **Urgent** pill |
        | `oldest_waiting_label` | "oldest waiting 4 days" |

        **Adaptive collapse.** A subject only becomes a group once it holds 3+
        items. Below that its asks arrive as single rows with `group: false`,
        because grouping a handful of notifications produces four groups of one —
        strictly worse than a short list. Read `group` to decide which shape to
        draw; both carry the same keys, so one list renderer handles both.

        For a group, `title` is the subject label; for a single row it is the
        notification's own title. `notifications` carries the entry's rows so a
        client can expand without a second request.

        ### Activity

        Everything that merely happened, **counted and never listed**: the zone
        is unbounded (it is the entire history of things that happened to you)
        and the point of it is that the user does not have to read the pile. Each
        subsection carries exactly four facts plus its identity — `count`,
        `unread_count`, `newest_at` (when the latest notification in it was
        received) and `icon`. There is deliberately no `notifications` array;
        fetch one subsection's rows with `GET /notifications?filter=<key>` (or the
        equivalent `?subject=<key>`) when
        the user expands it.

        Subsections with something unread come first, each set ordered by its newest
        notification (`ordering: "unread_first_then_newest"`).

        ### Visibility and the empty state

        Both sections show only what the inbox can currently display: archived,
        snoozed and expired rows are excluded. `empty` is true when both zones
        are empty. `snoozed_count` is reported only when nothing is waiting on
        the user, and exists to explain a zero — a snoozed ask the bell had
        counted is invisible here, and without naming it "You're all caught up"
        reads as a contradiction.

        Related endpoints: `GET /notifications/open_asks_count` for the bell
        badge alone, and `GET /notifications?filter=<key>` for one subject's
        rows.
      security:
      - BearerAuth: []
      parameters:
      - name: subject
        in: query
        required: false
        description: |
          Narrow BOTH sections to one subject key (a `key` from either section), matching the web `?subject=` filter. Omit it for both zones in full.

          Spelling is case- and separator-insensitive over the key, exactly as on `?filter=`. An unrecognised key is a `400 invalid_subject` rather than an empty screen: narrowing both zones to nothing would answer `empty: true` and read as "you're all caught up" when the truth is that the filter does not exist. The error message names near-miss keys.
        schema:
          type: string
        example: leave_notifications
      responses:
        '200':
          description: The two zones, sectioned and ordered as the web renders them
          content:
            application/json:
              schema:
                type: object
                required:
                - needs_you
                - activity
                - empty
                - snoozed_count
                properties:
                  needs_you:
                    "$ref": "#/components/schemas/NotificationsHomeNeedsYou"
                  activity:
                    "$ref": "#/components/schemas/NotificationsHomeActivity"
                  empty:
                    type: boolean
                    description: True when both sections are empty.
                    example: false
                  snoozed_count:
                    type: integer
                    description: 'Items the user parked, which are hidden from both
                      sections and from the bell. Reported only when nothing is waiting
                      (otherwise 0), because its only job is to explain a zero — and
                      narrowed by `subject` when one was given, so it explains the
                      zero on the screen the caller is looking at rather than naming
                      parked items from other subjects.

                      '
                    example: 0
        '400':
          description: "`invalid_subject` — `subject` names no known subject key.
            The message names near-miss keys."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/notifications/dismiss_subject":
    post:
      tags:
      - Notifications
      summary: Dismiss a whole Activity subsection ("Dismiss these")
      description: |
        Clear one Activity subsection in a single call — the native twin of the
        **Dismiss these** button the web `/inbox` and `/m/inbox` render at the
        foot of an expanded Activity group. Pass a `key` straight off
        `GET /notifications/home` → `activity.sections[].key` and every
        notification that section counted is archived.

        Activity items are never individually actionable, which is why the zone
        offers no per-row verb to loop instead: the alternative is asking the
        user to tap twenty times, and twenty round-trips, to reach the same
        state. This is one request and two queries however large the pile.

        ### What it takes

        Exactly the population the section's count described, so the number on
        the chip is the number of rows that move:

        | included | excluded |
        |---|---|
        | Informational rows (things that merely happened) | Open asks — the **Needs you** zone, whatever subject they share |
        | Read rows as well as unread — a read pile is still a pile | Snoozed rows: the user parked them, and they were never counted |
        | | Expired rows, and rows already archived |

        It **archives**, so it is reversible: dismissed rows stay findable under
        `GET /notifications?folder=archived`, and
        `PATCH /notifications/{id}/unarchive` puts one back.

        Idempotent — dismissing an already-cleared subject is a `200` with
        `count: 0`, not an error.

        ### Choosing a subject

        `subject` is **required**. Over HTTP an omitted field is far more likely
        to be a client bug than an instruction to clear the whole zone, so
        clearing everything takes the literal word `all`.

        Values are the subject keys the notification-category registry derives —
        the same ones `/notifications/home` emits and `?filter=` accepts
        (`wikis`, `system_announcements`, `kind:dm`, …). Spelling of a KEY is
        case- and separator-insensitive, so `news_feed_notifications`,
        `news-feed-notifications` and `News Feed Notifications` are one value.
        Display **labels** are not accepted — the label of
        `news_feed_notifications` is "News Feed", and `subject=News Feed` is a
        `400` whose message names the real key. Send the `key`, not the `label`.
        An unrecognised key is a `400`, never an empty success: resolving it to
        zero rows would report "dismissed 0" for a subject the user can plainly
        see twelve of.

        ### Repainting the screen

        The response carries the count and nothing else. Refetch
        `GET /notifications/home` when you want the server's numbers back;
        recomputing both zones on every dismiss would charge every caller for a
        payload most of them discard, since a client that just cleared a section
        already knows to drop it. The bell needs no second call either —
        `unread_notification_count` rides along on this response as on every
        other.
      security:
      - BearerAuth: []
      parameters:
      - name: subject
        in: query
        required: false
        description: 'The subject to dismiss. May be sent as a query parameter or
          in the request body; the body is preferred. See `subject` in the request
          body schema.

          '
        schema:
          type: string
        example: system_announcements
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required:
              - subject
              properties:
                subject:
                  type: string
                  description: 'An activity subject key from `GET /notifications/home`
                    (`activity.sections[].key`), or `all` to dismiss the whole Activity
                    zone. Case- and separator-insensitive.

                    '
                  example: system_announcements
      responses:
        '200':
          description: 'The subsection was dismissed. `count` is how many notifications
            were archived, and is 0 when there was nothing left to clear.

            '
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - subject
                - all
                - count
                properties:
                  success:
                    type: boolean
                    example: true
                  subject:
                    type: string
                    description: 'The canonical subject key that was dismissed, or
                      `all`. Echoed so a client that sent a non-canonical spelling
                      of the key ("News-Feed-Notifications", "ALL") can match the
                      answer to the section it cleared.

                      '
                    example: system_announcements
                  all:
                    type: boolean
                    description: 'True when the whole Activity zone was cleared rather
                      than one subsection. Stated rather than left to be inferred
                      from `subject`, because an undo prompt or an analytics event
                      needs to know which of the two happened.

                      '
                    example: false
                  count:
                    type: integer
                    description: Notifications archived by this call.
                    example: 12
        '400':
          description: "`subject_required` when `subject` is missing or blank, or
            `invalid_subject` when it names no known subject. The message names a
            value that would have worked.\n"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/notifications/{id}/mark_as_read":
    patch:
      tags:
      - Notifications
      summary: Mark a notification as read
      description: |
        Marks a single notification as read. Sets the `read` flag to `true`
        and records the `read_at` timestamp.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification marked as read
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/{id}/mark_as_unread":
    patch:
      tags:
      - Notifications
      summary: Mark a notification as unread
      description: |
        Marks a single notification as unread. Sets the `read` flag to `false`
        and clears the `read_at` timestamp.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification marked as unread
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/mark_all_as_read":
    patch:
      tags:
      - Notifications
      summary: Mark all notifications as read
      description: |
        Marks multiple notifications as read in bulk. If `ids` is provided in the
        request body, only those specific notifications are marked. Otherwise, all
        unread notifications for the current user are marked as read.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                  description: Optional list of specific notification IDs to mark
                    as read. If omitted, marks all unread notifications.
                  example:
                  - 1
                  - 2
                  - 3
      responses:
        '200':
          description: Notifications marked as read
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - count
                properties:
                  success:
                    type: boolean
                    example: true
                  count:
                    type: integer
                    description: Number of notifications marked as read
                    example: 5
        '401':
          description: Unauthorized
  "/notifications/{id}/archive":
    patch:
      tags:
      - Notifications
      summary: Archive a notification
      description: |
        Archives a single notification. Archived notifications are excluded from
        the default notification list.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification archived
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/{id}/unarchive":
    patch:
      tags:
      - Notifications
      summary: Unarchive a notification
      description: 'Restores a previously archived notification back to the active
        list.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Notification ID
        schema:
          type: integer
      responses:
        '200':
          description: Notification unarchived
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
                  notification:
                    "$ref": "#/components/schemas/Notification"
        '401':
          description: Unauthorized
        '404':
          description: Notification not found
  "/notifications/archive_all":
    patch:
      tags:
      - Notifications
      summary: Archive multiple notifications
      description: |
        Archives multiple notifications in bulk. If `ids` is provided, only those
        specific notifications are archived. Otherwise, all notifications for the
        current user are archived.

        Pass `unarchive: true` to unarchive instead of archive.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  items:
                    type: integer
                  description: Optional list of specific notification IDs to archive
                  example:
                  - 1
                  - 2
                  - 3
                unarchive:
                  type: boolean
                  description: If true, unarchives the notifications instead
                  default: false
      responses:
        '200':
          description: Notifications archived/unarchived
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
  "/broadcasts":
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List the caller's received broadcasts (Dashboard inbox)
      description: |
        Returns the SAME broadcasts and ordering the web Broadcast app shows in
        the Dashboard > "Broadcasts" section: the signed-in user's personal
        inbox of **published** broadcasts they received (snapshot membership) or
        authored — never the full business broadcast list.

        Ordering matches the web priority sort: critical + unacknowledged first,
        then critical + unread, then critical, then unacknowledged, then unread,
        then read; ties break by `published_at DESC`, then `id DESC`.

        Supports the same four Dashboard subfilters via `filter`. Delegates to
        `BroadcastQueries#my_broadcasts` so the API can't drift from the web.
      parameters:
      - name: filter
        in: query
        description: |
          Item subfilter. Default `all`.
            * `all`         — every received published broadcast
            * `unread`      — broadcasts the caller has not viewed
            * `critical`    — `is_critical = true`
            * `acknowledge` — EVERY acknowledgment-required broadcast
                              (`require_acknowledgment = true`), regardless of
                              whether the caller has acknowledged. (This differs
                              from the web "Acknowledge" chip, which shows only
                              pending acknowledgments.)
        required: false
        schema:
          type: string
          enum:
          - all
          - unread
          - critical
          - acknowledge
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Broadcasts listed (mirrors GET /inspections — collection +
            meta)
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcasts
                - pending_approvals
                - pending_acknowledgements
                - can_manage
                - meta
                properties:
                  broadcasts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BroadcastSummary"
                  pending_acknowledgements:
                    type: array
                    maxItems: 25
                    description: |
                      Published, acknowledgment-required broadcasts the caller was
                      SENT and has NOT acknowledged yet ({id, title} only), newest
                      published first, capped at 25 (a preview, not the whole set).
                      A broadcast the caller only authored is never listed.
                      Independent of pagination/?filter=. The same set
                      meta.segment_counts.acknowledge counts.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                  pending_approvals:
                    type: array
                    description: |
                      Broadcasts awaiting the CALLER's approval (pending Comms Hub
                      approval requests whose current step targets the caller's
                      role; admins see all). Same item shape as `broadcasts`.
                      Independent of pagination/?filter=.
                    items:
                      "$ref": "#/components/schemas/BroadcastSummary"
                  can_manage:
                    type: boolean
                    description: |
                      Root-level capability flag (not per item): whether the caller
                      can manage broadcasts generally — admin/above or the
                      broadcasts manage/edit permission.
                  meta:
                    "$ref": "#/components/schemas/BroadcastListMeta"
        '401':
          description: Authentication required
        '403':
          description: The Broadcasts & Alerts app is not enabled for this business
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Create and send a broadcast immediately (composers)
      description: |
        Mirrors the web composer's **Send Now** — there is no draft on mobile.
        The broadcast is created and **published immediately** (status
        `published`, fan-out begins). Requires the broadcasts **create**
        permission (admins/super-admins have it; managers/members when granted),
        or a Communications app admin or contributor role — company-wide or
        limited to places and departments (Knowledge Layer X1). A limited one's
        send reaches only the people inside their places and departments.

        **Approval:** if an enforced approval workflow governs broadcasts, a
        non-admin's send is routed through approval instead of publishing — the
        broadcast is submitted for review and the response returns
        `status: "pending_approval"`. Admins override approval and publish
        directly (same as web).

        At least one recipient target MUST be supplied (`audience_id`,
        `notification_recipient_group_ids`, `extra_user_ids`, or
        `audience_criteria`); a request with no target — or one that resolves to
        nobody in this business — returns `422 no_recipients` and nothing is
        persisted (no lingering draft).

        **Send later:** pass `scheduled_at` and the broadcast is left in status
        `scheduled` for the tick job instead of fanning out now — the response
        carries `status: "scheduled"`. The approval and moderation gates run
        FIRST, so a broadcast that would be held for review cannot slip out on a
        timer. A past or unparseable time is `422 invalid_scheduled_at`, never a
        silent immediate send.

        **Title is optional.** The unified Communications composer has no Title
        field, so a blank or absent `title` is derived from the first line of
        `description` (capped at a headline length) rather than rejected. A
        blank `description` is still a validation error.

        **Break-room screens:** pass `publish_to_signage` (optionally narrowed
        by `signage_location_ids`) to also put the broadcast into the Digital
        Signage rotation. Screens PULL — nothing is delivered by this request,
        and each screen's own content rules still decide whether it shows. If
        the tenant has no active screen the broadcast still sends on its other
        channels and the 201 carries a `warnings` entry saying nothing was
        queued, rather than failing the send.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - broadcast
              properties:
                campaign_id:
                  type: integer
                  nullable: true
                  description: |
                    Optional — the Communications campaign to put the broadcast
                    in (choices: `GET /comms_hub/campaigns`); null (form-encoded: an empty
                    value) takes it out,
                    and leaving it out keeps whatever campaign it is in. The
                    broadcast is saved (and sent) either way: a campaign the
                    caller may not use, or one that is finished, comes back as
                    a `warnings` sentence saying why it is not in it — also for
                    someone with no campaign to use. Naming the campaign it is
                    already in (or null for one in none) changes nothing.
                broadcast:
                  type: object
                  required:
                  - description
                  properties:
                    title:
                      type: string
                      description: |
                        Optional. The subject line every channel renders (push,
                        email, SMS) and the headline of the broadcast's News
                        Feed record. When blank or absent it is DERIVED from the
                        first line of `description`, so a composer with no Title
                        field never has to send the message twice.
                    description:
                      type: string
                      description: The message body. Required.
                    is_critical:
                      type: boolean
                    require_acknowledgment:
                      type: boolean
                    allow_comments:
                      type: boolean
                    allow_reactions:
                      type: boolean
                    auto_widen_channels:
                      type: boolean
                      description: |
                        Automatically widen the delivery channel while an
                        acknowledgement is still outstanding: each reminder adds
                        the next channel (in-app + email, then +SMS, then
                        +voice). Only meaningful together with
                        `require_acknowledgment` — the reminder job that drives
                        it runs for required-reading broadcasts only. Honors
                        quiet hours, channel rules and notification preferences.
                        Omit the key to leave it at the tenant default (off).
                    delivery_mode:
                      type: string
                      enum:
                      - immediate
                      - on_shift_only
                      - next_shift_start
                      default: immediate
                      description: |
                        The shift-aware delivery window:

                          * `immediate` — send to everyone now (default).
                          * `on_shift_only` — deliver only to recipients who are
                            currently on shift; off-shift recipients are
                            suppressed.
                          * `next_shift_start` — hold off-shift recipients and
                            deliver at the start of their next shift.

                        Critical alerts ignore this and always break through
                        immediately. An unrecognized value degrades to
                        `immediate` rather than holding the send back.
                    scheduled_at:
                      type: string
                      format: date-time
                      description: |
                        Send the broadcast at a future time instead of now. Parsed
                        in the CALLER's time zone, so a bare
                        `"2026-08-12 09:00"` means 9am where the author is. The
                        record is left in status `scheduled` and published by
                        `ScheduledBroadcastPublishJob`. Must be in the future —
                        a past or unparseable value returns
                        `422 invalid_scheduled_at`.
                    channels:
                      type: array
                      description: |
                        Delivery channels to ENABLE (email / sms / voice). Any not
                        listed are disabled. `in_app` always delivers and `push`
                        stays on regardless. Omit the key entirely to leave all
                        channels at their default (on).
                      items:
                        type: string
                        enum:
                        - email
                        - sms
                        - voice
                    publish_to_signage:
                      type: boolean
                      description: |
                        The composer's **Break-room screens** channel — mark this
                        broadcast for the Digital Signage rotation, the channel
                        that reaches frontline staff with no work phone and no
                        work email.

                        It is a REQUEST, not a delivery. Screens PULL: each one
                        shows the broadcast on its next refresh, and only where
                        its signage admin has "Communications posts" switched on.
                        Only PUBLISHED broadcasts are picked up, and only for 14
                        days, so a draft or a held send never reaches a wall.

                        Screens are token-authenticated PUBLIC web pages —
                        anyone standing in the room can read them. Only set this
                        for something you would put on a wall.

                        Deliberately separate from `channels`: that array is the
                        per-USER delivery map, and a screen is a place, not a
                        recipient.

                        Omit the key to leave the selection unchanged. On PATCH,
                        send `false` to take a broadcast back off the screens.
                    signage_location_ids:
                      type: array
                      description: |
                        Narrow `publish_to_signage` to specific sites. Omit or
                        send an empty array to show on every screen. Ids outside
                        the caller's business are dropped, and the key is ignored
                        entirely unless `publish_to_signage` is on.
                      items:
                        type: integer
                    audience_id:
                      type: integer
                      nullable: true
                      description: A saved CommsHub audience to send to.
                    extra_user_ids:
                      type: array
                      description: Specific user ids to send to.
                      items:
                        type: integer
                    audience_criteria:
                      type: array
                      description: |
                        Attribute-based audience filters, each a typed hash — e.g.
                        `{ "type": "role", "roles": ["member"] }`,
                        `{ "type": "job_title", "titles": ["Area Manager"] }`,
                        `{ "type": "department", "ids": [1,2] }`,
                        `{ "type": "location", "ids": [3] }`.
                      items:
                        type: object
                        additionalProperties: true
                    media_signed_ids:
                      type: array
                      description: |
                        Attachments by reference: ActiveStorage blob
                        `signed_id`s the client already holds. Prefer
                        `media_files`. The generic
                        `POST /rails/active_storage/direct_uploads` endpoint is
                        disabled (403) and is not a way to obtain one. Subject
                        to the same media rules; any invalid or expired
                        reference fails the whole call with 422.
                      items:
                        type: string
                notification_recipient_group_ids:
                  type: array
                  description: Notification recipient group ids to send to (top-level,
                    not under `broadcast`).
                  items:
                    type: integer
          multipart/form-data:
            schema:
              type: object
              description: 'The SAME body, form-encoded, plus the attachment file
                parts. A file can only be sent this way — `format: binary` inside
                a JSON-only requestBody is something no generated client (and no Swagger
                UI) can actually post. Every other field is sent with its bracketed
                name, e.g. `broadcast[description]`, `broadcast[is_critical]`, `notification_recipient_group_ids[]`.'
              properties:
                broadcast[media_files][]:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    format: binary
                  description: The attachment file parts, uploaded in THIS request.
                    Works with every authentication method, Bearer tokens included.
                    They land in the broadcast's `media_files` (Drive) — the same
                    attachments the web composer and the show API expose. Validated
                    server-side against the broadcast media rules (image/pdf/video,
                    per-file size cap, max 10 files, counted together with `media_signed_ids`);
                    any invalid file fails the whole call with 422 `invalid_attachments`
                    and nothing is persisted.
                broadcast[media_signed_ids][]:
                  type: array
                  items:
                    type: string
                  description: Attachments by reference — see the JSON body's `media_signed_ids`.
                broadcast[description]:
                  type: string
                broadcast[title]:
                  type: string
      responses:
        '201':
          description: |
            Broadcast created. Normally **published** immediately
            (`broadcast.status: "published"`). Two other outcomes are also 201:

              * **submitted for approval** — an enforced approval workflow
                governs broadcasts and the caller is NOT an admin (admins
                override), or the request set `submit_for_approval`. The
                response carries `status: "pending_approval"`; an approver sends
                it later via `POST /approvals/{id}/approve`.

                `broadcast.status` is `"draft"` for a send-now composition, and
                `"scheduled"` when the request also carried `scheduled_at` — the
                author's send time is KEPT through the review so the approver
                can see when it goes out and it still sends at that time once
                approved. It cannot slip out early: the tick job refuses any
                broadcast whose approval request is still pending.
              * **scheduled** — `scheduled_at` was supplied. The response carries
                `status: "scheduled"` and `broadcast.status: "scheduled"`; the
                tick job publishes it at the requested time.
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  channels:
                    type: array
                    description: The channels the broadcast will fan out on (in_app
                      always; email/sms/voice/push unless toggled off).
                    items:
                      type: string
                  attachments:
                    type: array
                    description: The attachments that were attached to the broadcast
                      (from media_files / media_signed_ids).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                  status:
                    type: string
                    enum:
                    - pending_approval
                    description: Present only when the send was routed through approval
                      instead of publishing.
                  message:
                    type: string
                  warnings:
                    type: array
                    description: |
                      Non-fatal notices about parts of the request that could not
                      be honoured — the broadcast still sent. Present only when
                      non-empty. Three things land here: the outcome of a
                      `publish_to_signage` request (the confirmation naming how
                      many screens it was queued for, or the reason nothing was
                      — no active screen registered, or none at the sites
                      picked), a count of any attachment that could not be
                      stored ("1 of 2 attachments could not be attached…"), so a
                      broadcast that went out without its photo says so, and why
                      it is not in the campaign `campaign_id` named.
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: No permission to create broadcasts, or app not enabled
        '402':
          description: |
            The cost step (texts and calls are paid). Asked only of a client
            that opts in with the `X-Cost-Confirmation: ask` header (or
            `cost_confirmation=ask`): when the send draws more platform credits
            than the cost threshold, nothing is sent and the answer is
            `{ error: { code: "confirm_required", message, details: { cost_preview } } }`;
            repeat the same request with `cost_confirmed=1` to go ahead. A client
            that does not opt in is never asked. For every client, a send the
            wallet or this month's budget cannot cover is refused with
            `{ error: { code: "insufficient_credits", message, details: { cost_preview } } }`.
        '422':
          description: |
            Validation failed (`validation_failed`); no recipient target supplied
            or it resolved to nobody (`no_recipients`); approval is required but
            the request could not be submitted (`approval_required`); or it was
            held for content review (`held_for_moderation`, with
            `error.details.broadcast_id`). On those two the broadcast is kept as
            a draft, and `error.details.warnings` says why it is not in the
            campaign `campaign_id` named, when it is not.
  "/broadcasts/templates":
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List broadcast templates for the composer's "Start from a template"
        picker
      description: |
        Powers the broadcast composer's "Start from a template" picker (opened
        from the broadcast list `+`). Returns the two groups the web Broadcast app
        offers:
          * `your_templates`    — this business's saved broadcast templates (ordered)
          * `gallery_templates` — the MangoApps starters written as Broadcasts (the
                                  Templates page's Template library set, which web
                                  Compose offers too), so an author can start from a
                                  pre-built composition. A starter is not a saved
                                  row: its `id` is null and `ref` names it.
        Each entry carries the fields the new-broadcast form pre-fills from a
        template (title ← `name`, description ← `body`, the critical / ack /
        comment / reaction flags, and the saved audience + recipient groups).
        Requires the broadcasts **create** permission — the same gate as
        `POST /broadcasts` (admins/super-admins always; managers/members when
        granted). Anyone who can create a broadcast can fetch its templates.
      responses:
        '200':
          description: Templates listed
          content:
            application/json:
              schema:
                type: object
                required:
                - your_templates
                - gallery_templates
                - meta
                properties:
                  your_templates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - ref
                      - name
                      - body
                      - system_template
                      properties:
                        id:
                          type: integer
                          nullable: true
                          description: The saved template's id; null for a MangoApps
                            starter.
                        ref:
                          type: string
                          description: |
                            How Compose names the template (`?template_id=`) — the id
                            for a saved template, `starter:<slug>` for a MangoApps starter.
                        name:
                          type: string
                          description: Template name — pre-fills the new broadcast's
                            title.
                        description:
                          type: string
                          nullable: true
                        body:
                          type: string
                          description: Template body — pre-fills the new broadcast's
                            description/body.
                        is_critical:
                          type: boolean
                        require_acknowledgment:
                          type: boolean
                        allow_comments:
                          type: boolean
                        allow_reactions:
                          type: boolean
                        audience_id:
                          type: integer
                          nullable: true
                          description: Saved CommsHub audience to pre-fill the recipient
                            picker, if any.
                        notification_recipient_group_ids:
                          type: array
                          items:
                            type: integer
                          description: Saved recipient group ids to pre-fill the recipient
                            picker.
                        system_template:
                          type: boolean
                          description: True for a platform-curated gallery template
                            (business_id is null).
                        metadata:
                          type: object
                          description: |
                            Saved composition extras the composer pre-fills from —
                            e.g. `channel_rules`, `audience_criteria`, `extra_user_ids`.
                          additionalProperties: true
                        created_at:
                          type: string
                          format: date-time
                          nullable: true
                  gallery_templates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - ref
                      - name
                      - body
                      - system_template
                      properties:
                        id:
                          type: integer
                          nullable: true
                          description: The saved template's id; null for a MangoApps
                            starter.
                        ref:
                          type: string
                          description: |
                            How Compose names the template (`?template_id=`) — the id
                            for a saved template, `starter:<slug>` for a MangoApps starter.
                        name:
                          type: string
                          description: Template name — pre-fills the new broadcast's
                            title.
                        description:
                          type: string
                          nullable: true
                        body:
                          type: string
                          description: Template body — pre-fills the new broadcast's
                            description/body.
                        is_critical:
                          type: boolean
                        require_acknowledgment:
                          type: boolean
                        allow_comments:
                          type: boolean
                        allow_reactions:
                          type: boolean
                        audience_id:
                          type: integer
                          nullable: true
                          description: Saved CommsHub audience to pre-fill the recipient
                            picker, if any.
                        notification_recipient_group_ids:
                          type: array
                          items:
                            type: integer
                          description: Saved recipient group ids to pre-fill the recipient
                            picker.
                        system_template:
                          type: boolean
                          description: True for a platform-curated gallery template
                            (business_id is null).
                        metadata:
                          type: object
                          description: |
                            Saved composition extras the composer pre-fills from —
                            e.g. `channel_rules`, `audience_criteria`, `extra_user_ids`.
                          additionalProperties: true
                        created_at:
                          type: string
                          format: date-time
                          nullable: true
                  meta:
                    type: object
                    required:
                    - your_templates_count
                    - gallery_templates_count
                    properties:
                      your_templates_count:
                        type: integer
                      gallery_templates_count:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: No permission to create broadcasts, or the app is not enabled
  "/broadcasts/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Get a broadcast
      description: |
        Returns the broadcast detail plus `meta.segment_counts` — the same
        { all, critical, acknowledge } NOT-READ inbox counts the list endpoint
        exposes, so the detail screen can keep the inbox badges current without a
        separate list request. The counts reflect the inbox state AFTER this
        broadcast is marked read by the fetch.
      responses:
        '200':
          description: Broadcast found
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                - meta
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  meta:
                    type: object
                    required:
                    - segment_counts
                    properties:
                      segment_counts:
                        type: object
                        description: |
                          NOT-READ inbox counts for the caller (mirrors the list
                          endpoint). `all` is the unread total — no separate
                          `unread` key.
                        required:
                        - all
                        - critical
                        - acknowledge
                        properties:
                          all:
                            type: integer
                          critical:
                            type: integer
                          acknowledge:
                            type: integer
        '401':
          description: Authentication required
        '403':
          description: Not permitted to view this broadcast (not a recipient, author,
            or manager/admin)
        '404':
          description: Broadcast not found
    patch:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Update a draft broadcast (admin only)
      description: |
        Only broadcasts still in `draft` status can be edited via the API.

        A PARTIAL edit: send only the attributes you are changing. Omitted keys
        are left alone — including `title`, which is no longer re-derived from a
        `description` you did send.

        `publish_to_signage` follows PATCH semantics: omit it to leave the
        break-room screen selection alone, send `false` to take the broadcast
        off the screens. A selection that could not be honoured comes back in a
        `warnings` array rather than failing the edit.

        Recipients (`extra_user_ids`) and the `channels` mix are set at create
        time and cannot be edited; sending either changes nothing and is named
        in `warnings`.

        Media can be ADDED by an edit: send the call as `multipart/form-data`
        with `broadcast[media_files][]` file parts (and/or
        `broadcast[media_signed_ids][]`), exactly as on create. They are counted
        with the files the draft already holds against the 10-file limit; an
        invalid file fails the edit with 422 `invalid_attachments` and nothing
        is changed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - broadcast
              properties:
                campaign_id:
                  type: integer
                  nullable: true
                  description: |
                    Optional — the Communications campaign to put the broadcast
                    in (choices: `GET /comms_hub/campaigns`); null (form-encoded: an empty
                    value) takes it out,
                    and leaving it out keeps whatever campaign it is in. The
                    broadcast is saved (and sent) either way: a campaign the
                    caller may not use, or one that is finished, comes back as
                    a `warnings` sentence saying why it is not in it — also for
                    someone with no campaign to use. Naming the campaign it is
                    already in (or null for one in none) changes nothing.
                broadcast:
                  "$ref": "#/components/schemas/BroadcastInput"
          multipart/form-data:
            schema:
              type: object
              description: The same edit, form-encoded, plus the attachment file parts
                — the only content type a file can actually be sent on. Every other
                field takes its bracketed name, e.g. `broadcast[allow_comments]`.
              properties:
                broadcast[media_files][]:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    format: binary
                  description: File parts to ADD to the draft. Counted with the files
                    it already holds against the 10-file limit; an invalid file fails
                    the edit with 422 `invalid_attachments` and nothing is changed.
                broadcast[media_signed_ids][]:
                  type: array
                  items:
                    type: string
                  description: Attachments by reference to ADD to the draft. Prefer
                    the file parts.
      responses:
        '200':
          description: Broadcast updated
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  warnings:
                    type: array
                    description: Non-fatal notices — present only when non-empty.
                      Carries the outcome of a `publish_to_signage` request (the confirmation
                      naming how many screens it was queued for, or the reason nothing
                      was), a note naming any create-only key (`extra_user_ids`, `channels`)
                      that was sent and therefore ignored, a count of any attachment
                      that could not be stored, and why the broadcast is not in the
                      campaign `campaign_id` named.
                    items:
                      type: string
        '403':
          description: Admin access required, or broadcast is not a draft
        '404':
          description: Broadcast not found
        '422':
          description: Validation failed
    delete:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Delete a draft or scheduled broadcast
      description: |
        The web "Delete": only a `draft` or `scheduled` broadcast can be
        deleted — deleting a scheduled one cancels its send, and a repeating
        one every later send. Published broadcasts are archived instead
        (`POST /broadcasts/{id}/archive`). Cannot be undone.

        Authorization is the web Delete's: an admin or a Broadcast /
        Communications app admin, the broadcasts `delete` permission, the
        broadcast's author, or a Communications admin limited to places and
        departments for a broadcast inside that reach.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:broadcasts` scope.
      responses:
        '200':
          description: Broadcast deleted
          content:
            application/json:
              schema:
                type: object
                required:
                - deleted
                - id
                properties:
                  deleted:
                    type: boolean
                  id:
                    type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller may not delete this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast is published or archived (`not_deletable`), or could
            not be deleted (`delete_failed`)
  "/broadcasts/{id}/publish":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Publish a draft or scheduled broadcast now
      description: |
        Runs the model's `publish!` so the API matches the web path: recipient
        snapshot, variant assignment, and the notification fan-out all run.
        A `scheduled` broadcast is sent now, as the web's "Send now" does.
      responses:
        '200':
          description: Broadcast published
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
        '403':
          description: No permission to publish this broadcast, or it is already published
            or archived
        '402':
          description: |
            The cost step (texts and calls are paid). Asked only of a client
            that opts in with the `X-Cost-Confirmation: ask` header (or
            `cost_confirmation=ask`): when the send draws more platform credits
            than the cost threshold, nothing is sent and the answer is
            `{ error: { code: "confirm_required", message, details: { cost_preview } } }`;
            repeat the same request with `cost_confirmed=1` to go ahead. A client
            that does not opt in is never asked. For every client, a send the
            wallet or this month's budget cannot cover is refused with
            `{ error: { code: "insufficient_credits", message, details: { cost_preview } } }`.
        '404':
          description: Broadcast not found
        '422':
          description: Could not publish broadcast
  "/broadcasts/{id}/unschedule":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Cancel a scheduled send (back to draft)
      description: |
        The web "Cancel schedule": a `scheduled` broadcast goes back to a
        draft, its send time and repeat cleared. An approval request still
        open on it is withdrawn with it — left open, approving it would send
        the draft at once — and `approval_withdrawn` says whether one was.
        Nothing changes unless both succeed.

        Authorization is the web's: whoever may publish the broadcast.
      responses:
        '200':
          description: Moved back to draft
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                - approval_withdrawn
                - message
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
                  approval_withdrawn:
                    type: boolean
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Caller may not publish this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast is not scheduled (`not_scheduled`), or the schedule
            could not be canceled (`unschedule_failed`)
  "/broadcasts/{id}/acknowledge":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Acknowledge a broadcast (recipient)
      description: |
        Records the caller's acknowledgment of a published broadcast that requires
        it. Recipients only: a caller the broadcast was not SENT to — its author,
        or an admin or manager who can open it — is refused 403 `not_a_recipient`.
        The broadcast payload's `can_acknowledge` says in advance whether this
        call will succeed. Repeating it for a recorded acknowledgement answers 200.
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                required:
                - acknowledged
                - broadcast_id
                properties:
                  acknowledged:
                    type: boolean
                  broadcast_id:
                    type: integer
        '401':
          description: Authentication required
        '403':
          description: The broadcast was not sent to the caller (`not_a_recipient`)
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast is not published or does not require acknowledgment
  "/broadcasts/{id}/viewers":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List recipients who have viewed the broadcast (read receipts)
      description: |
        Read-receipt roster powering the "Viewed by N of M" flyout on the
        Broadcast detail screen. Returns the recipients who have **viewed** the
        broadcast, ordered most-recently-viewed first and paginated, plus the
        total-recipients and unique-view counts in `meta`.

        **Works councils (Knowledge Layer X3).** Where a works council hides
        names in read reports (General Settings › Countries), those viewers
        are left out of `viewers` before paging and counted in `names_hidden`;
        `unique_view_count` still counts everyone.

        While the audience's hidden group is smaller than the company's
        smallest reportable group (Admin › Compliance › Retention & Legal
        Holds), the list never says how many of those few people viewed it:
        `names_hidden` is present whether or not it left anyone out, with a
        null `count` and `group_count` and `fewer_than` ("a few"), and
        `unique_view_count` counts only the named viewers (equal to
        `total_count`) — so nothing subtracts down to a hidden person.

        Accessible to anyone who can see the broadcast itself: a **recipient**
        (in the recipient list), the **author**, or a **manager/admin**. Drafts
        have no recipient list, so they are limited to the author and
        managers/admins. Anyone else gets `403`.
      parameters:
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Viewers listed (most-recently-viewed first)
          content:
            application/json:
              schema:
                type: object
                required:
                - viewers
                - meta
                properties:
                  viewers:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - viewed_at
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Viewer's job title (row subtitle).
                        location:
                          type: string
                          nullable: true
                          description: Viewer's office location (row subtitle).
                        viewed_at:
                          type: string
                          format: date-time
                          nullable: true
                  meta:
                    type: object
                    required:
                    - total_recipients_count
                    - unique_view_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_recipients_count:
                        type: integer
                        description: Total recipients the broadcast reached (snapshot
                          size — the "M").
                      unique_view_count:
                        type: integer
                        description: |
                          Distinct recipients who have viewed it (the "N") — everyone, named or
                          not; only the named viewers while `names_hidden.count` is null (the
                          hidden group is under the company's smallest group).
                      total_count:
                        type: integer
                        description: The viewers this list names (`unique_view_count`
                          minus `names_hidden.count`).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                  names_hidden:
                    "$ref": "#/components/schemas/BroadcastNamesHidden"
        '401':
          description: Authentication required
        '403':
          description: Caller is not a recipient, the author, or a manager/admin for
            this broadcast
        '404':
          description: Broadcast not found
  "/broadcasts/{id}/acknowledgements":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List recipients by acknowledgement state (Acknowledgement tracking)
      description: |
        Acknowledgement-tracking roster powering the "Acknowledgement tracking"
        flyout (Acknowledged / Pending tabs) on the Broadcast detail screen.
        Returns the broadcast's recipients filtered by whether they have
        acknowledged it, in the same order as the web "View Recipients" page
        (user id), paginated, plus the total / acknowledged / not-acknowledged
        counts in `meta`.

        Only meaningful for `require_acknowledgment` broadcasts (others return
        `422`). Restricted to callers who can see "View stats" — a **published**
        broadcast they can **manage** (admin/manager, or the author when a
        manager+, or a Communications admin limited to places and departments
        whose reach covers it). Anyone else gets `403`.

        **Works councils (Knowledge Layer X3).** Where a works council hides
        names in confirmation reports, both tabs list only the people they may
        name (left out before paging); `names_hidden` counts the ones this tab
        leaves out. The counts in `meta` other than `total_count` count everyone.

        While the audience's hidden group is smaller than the company's
        smallest reportable group, `acked_count` and `not_acked_count` count
        only the named people, `total_recipients_count` stays the whole
        audience, and `names_hidden` (on both tabs, whether or not the tab
        left anyone out) has a null `count` and `group_count` and
        `fewer_than` ("a few") — so `acked_count` minus this tab's `total_count` says
        nothing about a hidden person.
      parameters:
      - name: type
        in: query
        description: |
          Which roster to return. Default `acked`.
            * `acked`     — recipients who HAVE acknowledged (each row carries
                            the `acknowledged_at` time).
            * `not_acked` — recipients who have NOT acknowledged
                            (`acknowledged_at` is null).
        required: false
        schema:
          type: string
          enum:
          - acked
          - not_acked
          default: acked
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed (filtered by acknowledgement state, user-id
            order)
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When this recipient acknowledged (null on the
                            not_acked roster).
                  meta:
                    type: object
                    required:
                    - type
                    - total_recipients_count
                    - acked_count
                    - not_acked_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      type:
                        type: string
                        description: The resolved roster type (acked | not_acked).
                      total_recipients_count:
                        type: integer
                        description: Total recipients the broadcast reached.
                      acked_count:
                        type: integer
                        description: Recipients who have acknowledged (only the named
                          ones while `names_hidden.count` is null).
                      not_acked_count:
                        type: integer
                        description: Recipients who have not acknowledged (only the
                          named ones while `names_hidden.count` is null).
                      total_count:
                        type: integer
                        description: Items matching the active type that this list
                          names (its size).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                  names_hidden:
                    "$ref": "#/components/schemas/BroadcastNamesHidden"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot see "View stats" (not published, or not a manager/admin/author)
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast does not require acknowledgment
  "/broadcasts/{id}/comments":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    get:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: List a broadcast's comments (paginated)
      description: |
        Full, paginated comment feed for the broadcast — the same top-level
        comments the detail screen renders. Each item has the IDENTICAL shape to
        the `recent_comments` items in GET /broadcasts/{id} ({ id, parent_id,
        body, mentioned_user_ids, created_at, author, attachments, replies });
        the only difference is this returns the WHOLE feed page-by-page instead
        of just the latest 5. Pages over top-level comments, newest first
        (`created_at DESC`); each carries its visible replies, oldest first, in
        `replies` (threads are one level deep).

        Accessible to anyone who can see the broadcast itself: a **recipient**,
        the **author**, or a **manager/admin** (same gate as show). Anyone else
        gets `403`.
      parameters:
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Comments listed (newest first)
          content:
            application/json:
              schema:
                type: object
                required:
                - comments
                - meta
                properties:
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BroadcastComment"
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller is not a recipient, the author, or a manager/admin for
            this broadcast
        '404':
          description: Broadcast not found
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Add a comment to a broadcast
      description: |
        Creates a comment, mirroring the web BroadcastCommentsController#create
        (including content moderation — a held comment is hidden until a moderator
        approves it and the response carries `held: true`).

        Accessible to anyone who can see the broadcast (recipient / author /
        manager / admin) when commenting is enabled on it (`allow_comments`).
        Returns the created comment in the canonical shape, including `can_edit`
        / `can_delete` for the caller.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: object
                  required:
                  - content
                  properties:
                    content:
                      type: string
                      description: The comment body.
                    parent_id:
                      type: integer
                      nullable: true
                      description: |
                        Set to reply to a comment; omit/null for a top-level
                        comment. Threads are one level deep, so this must be a
                        TOP-LEVEL comment on the same broadcast (to answer a
                        reply, send its `parent_id`) — anything else is `422`.
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BroadcastCommentWriteResult"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot view this broadcast, or commenting is disabled
            (`comments_disabled`)
        '404':
          description: Broadcast not found
        '422':
          description: Validation failed (e.g. blank content)
  "/broadcasts/{id}/comments/{comment_id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    - name: comment_id
      in: path
      required: true
      description: Comment ID
      schema:
        type: integer
    patch:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Edit a comment (author or admin)
      description: |
        Updates a comment's content. Permission matches the `can_edit` flag: a
        company admin or a Broadcast / Communications app admin, or the
        comment's author for 15 minutes after posting while Communications
        allows editing posts and comments — the same rule as the web and phone
        edit. Anyone else gets `403`; an archived broadcast's comments are
        closed (`403 comments_closed`). Sets `edited_at`. Re-runs content
        moderation like the web edit.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: object
                  required:
                  - content
                  properties:
                    content:
                      type: string
                      description: |
                        The comment body. An edit changes the words only — a
                        comment keeps the place in the thread it was posted in
                        (any `parent_id` sent here is ignored). Each @mention is
                        the token `@[Name](mention:<user id>)`; a plain `@Name`
                        for a mention the comment already held keeps it.
      responses:
        '200':
          description: Comment updated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BroadcastCommentWriteResult"
        '401':
          description: Authentication required
        '403':
          description: Caller is neither the comment's author nor an admin (`forbidden`)
        '404':
          description: Broadcast or comment not found
        '422':
          description: Validation failed (e.g. blank content)
    delete:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Delete a comment (author or admin)
      description: |
        Soft-deletes a comment. Permission matches the `can_delete` flag and the
        web delete visibility: the comment's author, a company admin, or a
        Broadcast / Communications app admin — the same rule as edit.

        Deleting a top-level comment deletes its replies with it (owner
        decision #7810), in a background job seconds after the response; a
        reply deleted with its parent then answers 404 on this path like any
        deleted comment.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:broadcasts` scope.
      responses:
        '200':
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - comment_id
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                  comment_id:
                    type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller is neither the comment's author nor an admin
        '404':
          description: Broadcast or comment not found
  "/broadcasts/{id}/resend":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Resend a broadcast to pending recipients ("Resend Now")
      description: |
        Like the web **Resend Now** control on the View-stats page
        (`BroadcastsController#resend_to_pending`). Re-delivers the broadcast to
        every recipient who has **not acknowledged** yet and is still an active
        member (the people the acknowledgement reminders chase), reusing the model's
        per-channel delivery (so delivery receipts and each user's notification
        preferences are honored).

        The resend goes over the channels the broadcast was **configured with at
        creation** (the author's per-broadcast channel toggles), NOT channels
        supplied in the request — there is no request body, and any `channels`
        param is ignored. The channels actually used are echoed back in the
        response `channels` array.

        Authorization mirrors the web: the caller must be able to **manage** the
        broadcast — an admin (or above), a holder of the broadcasts `manage`/`edit`
        permission, or the author when a manager+. Otherwise `403`.

        Only valid for a **published**, **acknowledgment-required** broadcast.
      responses:
        '200':
          description: Resent to pending recipients
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast_id
                - channels
                - resent_count
                - failed_count
                - message
                properties:
                  broadcast_id:
                    type: integer
                  channels:
                    type: array
                    description: The creation-time channels the broadcast was resent
                      over (subset of `email` / `sms` / `voice`).
                    items:
                      type: string
                  resent_count:
                    type: integer
                    description: Number of pending recipients (active members who
                      have not acknowledged) for whom at least one channel was sent
                      or queued. A channel skipped for them (no email or verified
                      phone on file, their preferences, the company's switch) or that
                      failed does not count.
                  failed_count:
                    type: integer
                    description: Number of pending recipients no channel reached —
                      every channel was skipped or failed; the delivery table on the
                      broadcast's stats page has each receipt and its reason.
                  message:
                    type: string
                  names_hidden:
                    type: object
                    description: Present while the audience's works-council group
                      is under the company's smallest group. `resent_count` and `failed_count`
                      then count only the named people; anyone counted, not named
                      who still owes it was resent too, and is never counted here.
        '401':
          description: Authentication required
        '403':
          description: Caller cannot manage this broadcast
        '402':
          description: |
            The cost step (texts and calls are paid). Asked only of a client
            that opts in with the `X-Cost-Confirmation: ask` header (or
            `cost_confirmation=ask`): when the send draws more platform credits
            than the cost threshold, nothing is sent and the answer is
            `{ error: { code: "confirm_required", message, details: { cost_preview } } }`;
            repeat the same request with `cost_confirmed=1` to go ahead. A client
            that does not opt in is never asked. For every client, a send the
            wallet or this month's budget cannot cover is refused with
            `{ error: { code: "insufficient_credits", message, details: { cost_preview } } }`.
        '404':
          description: Broadcast not found
        '422':
          description: Not a published, acknowledgment-required broadcast, or no resendable
            channel was enabled at creation
  "/broadcasts/{id}/remind":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Send an acknowledgment reminder to a single recipient
      description: |
        Sends an acknowledgment reminder to ONE recipient — the same reminder the
        web sends automatically (`BroadcastAcknowledgmentReminderJob#send_reminder`):
        an in-app Notification plus the reminder email, bumping `reminder_count`
        and `last_reminder_at` on the recipient's status row.

        Authorization mirrors the web reminder/resend controls: the caller must be
        able to **manage** the broadcast (admin/above, broadcasts `manage`/`edit`
        permission, or the author when a manager+). Otherwise `403`.

        Only valid for a **published**, **acknowledgment-required** broadcast and a
        recipient who has **not acknowledged** yet.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                  description: The recipient to remind.
      responses:
        '200':
          description: |
            Reminder sent — or, for a recipient a works council keeps
            confirmation reports from naming (Knowledge Layer X3), the
            status-hidden answer: the reminder goes only if they still owe the
            acknowledgment, and the answer is the same either way
            (`status_hidden: true`, no `reminder_count` / `last_reminder_at`,
            never `already_acknowledged`).
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  title: ReminderSent
                  required:
                  - broadcast_id
                  - user_id
                  - reminder_count
                  - message
                  properties:
                    broadcast_id:
                      type: integer
                    user_id:
                      type: integer
                    reminder_count:
                      type: integer
                      description: The recipient's cumulative reminder count after
                        this send.
                    last_reminder_at:
                      type: string
                      format: date-time
                    message:
                      type: string
                - type: object
                  title: ReminderStatusHidden
                  required:
                  - broadcast_id
                  - user_id
                  - status_hidden
                  - message
                  properties:
                    broadcast_id:
                      type: integer
                    user_id:
                      type: integer
                    status_hidden:
                      type: boolean
                      enum:
                      - true
                      description: Whether this person had acknowledged is not shown
                        (works council).
                    message:
                      type: string
                      example: If they haven't acknowledged yet, they've been reminded.
                        Whether they have isn't shown here.
                    names_hidden:
                      "$ref": "#/components/schemas/BroadcastNamesHidden"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot manage this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: |
            Not a published / acknowledgment-required broadcast (`cannot_remind`),
            `user_id` missing or not in the business (`user_not_found`), the user is
            not a recipient (`not_a_recipient`), or the user already acknowledged
            (`already_acknowledged`).
  "/broadcasts/{id}/archive":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Archive a published broadcast
      description: |
        Archives a **published** broadcast (status → `archived`), mirroring the
        web "Archive" action.

        Authorization mirrors the web archive: the caller must be able to
        **manage** the broadcast (admin/above, broadcasts `manage`/`edit`
        permission, or the author when a manager+). Otherwise `403`.

        Only **published** broadcasts can be archived — drafts and scheduled
        broadcasts return `422` (they are deleted, not archived).

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:broadcasts` scope.
      responses:
        '200':
          description: Broadcast archived (returns the updated broadcast)
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast
                properties:
                  broadcast:
                    "$ref": "#/components/schemas/BroadcastDetail"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot manage this broadcast
        '404':
          description: Broadcast not found
        '422':
          description: Broadcast is not published (`not_published`), or archiving
            failed (`archive_failed`)
  "/broadcasts/{broadcast_id}/reactions/toggle":
    parameters:
    - name: broadcast_id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      summary: Toggle (add/remove) an emoji reaction on a broadcast
      security:
      - BearerAuth: []
      description: |
        Adds or removes the caller's emoji reaction, mirroring the web
        `BroadcastReactionsController#toggle` (a single TOGGLE per emoji via
        `Platform::Reactable#toggle_reaction`): re-sending the same emoji removes
        the caller's reaction, otherwise it adds one. `reacted` in the response is
        `true` when the reaction is now ON (added), `false` when removed.

        Accessible to anyone who can see the broadcast (recipient / author /
        manager / admin) when reactions are enabled on it (`allow_reactions`).
        The emoji must be in the company's reaction palette (its admin's choice from the one reaction catalog, the whole catalog unless narrowed),
        or one the caller already holds from outside it (so it can be removed).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: The emoji to toggle. Must be in the broadcast's allowed
                    reaction set.
      responses:
        '200':
          description: Reaction toggled
          content:
            application/json:
              schema:
                type: object
                required:
                - broadcast_id
                - emoji
                - reacted
                - reactions
                - my_reactions
                properties:
                  broadcast_id:
                    type: integer
                  emoji:
                    type: string
                  reacted:
                    type: boolean
                    description: True when the reaction is now ON (added); false when
                      it was removed.
                  reactions:
                    type: array
                    description: Updated reaction tallies for the broadcast.
                    items:
                      type: object
                      required:
                      - emoji
                      - count
                      properties:
                        emoji:
                          type: string
                        count:
                          type: integer
                  my_reactions:
                    type: array
                    description: The caller's current emoji reactions on this broadcast.
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: Caller cannot view this broadcast, or reactions are disabled
            (`reactions_disabled`)
        '404':
          description: Broadcast not found
        '422':
          description: Emoji not in the broadcast's allowed reaction set (`invalid_emoji`)
  "/broadcasts/{id}/placements/{placement_id}/dismiss":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    - name: placement_id
      in: path
      required: true
      description: The placement's `id` (a `placements[]` entry — see `CommsPlacement`).
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: Dismiss one of this broadcast's placements for the caller
      description: |
        The native twin of the web card's Dismiss on a placed broadcast (a
        shift's message, Top of Home, a store's pin). Records the same
        dismissal event the web card does, for the caller only; it never ends
        the placement for anyone else (`end_now` does). The placement must
        belong to this broadcast and be live, and the caller must be able to
        see the broadcast. Needs `write:broadcasts`. A feed post's placement is
        dismissed through `POST /feeds/{id}/placements/{placement_id}/dismiss`.
      responses:
        '200':
          description: Dismissed
          content:
            application/json:
              schema:
                type: object
                properties:
                  dismissed:
                    type: boolean
                    example: true
                  placement_id:
                    type: integer
        '401':
          description: Authentication required
        '403':
          description: Missing `write:broadcasts`, or the caller cannot see this broadcast
        '404':
          description: 'Broadcast not found, or `{ error: { code: "not_found", message:
            "Placement not found" } }` — not this broadcast''s, or not live'
  "/broadcasts/{id}/placements/{placement_id}/end_now":
    parameters:
    - name: id
      in: path
      required: true
      description: Broadcast ID
      schema:
        type: integer
    - name: placement_id
      in: path
      required: true
      description: The placement's `id`.
      schema:
        type: integer
    post:
      tags:
      - Broadcasts
      security:
      - BearerAuth: []
      summary: End one of this broadcast's placements now ("End now")
      description: |
        Takes the placement down for everyone (Knowledge Layer E7). Allowed to
        the author, or to someone the booking rule lets place it (a
        Communications admin whose reach covers its audience; a store manager
        for their store's pin). On a queued place (Top of Home) the booking
        waiting right behind it starts now — `next_placement_id` names it.
        Needs `write:broadcasts`. The same endpoint exists for a feed post:
        `POST /feeds/{id}/placements/{placement_id}/end_now`.
      responses:
        '200':
          description: Ended
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PlacementEndResult"
        '401':
          description: Authentication required
        '403':
          description: Missing `write:broadcasts`, or `{ error }` — the caller may
            not end this placement (the refusal's own words)
        '404':
          description: 'Broadcast not found, or `{ error: { code: "not_found", message:
            "Placement not found" } }` — not this broadcast''s current placement'
        '422':
          description: '`{ error }` — e.g. "It has already ended."'
  "/approvals/{id}/approve":
    parameters:
    - name: id
      in: path
      required: true
      description: Communications approval request id (a source's `pending_approvals`
        item's `approval_id`, or `id` from GET /comms_hub/approval_requests).
      schema:
        type: integer
    post:
      tags:
      - Approvals
      security:
      - BearerAuth: []
      summary: Approve a pending approval request
      description: |
        Records the caller's **approve** decision on a pending Communications
        approval request — for a post, a broadcast, an alert or a newsletter
        issue — mirroring
        `Apps::CommsHub::ApprovalsController#approve`. Appends an entry to the
        request's audit trail and advances the workflow: when the approved step
        is the last one the status becomes `approved`; otherwise it moves to the
        next step (still `pending`). Approving the last step releases what was
        held: a post publishes (or goes back onto its schedule), a broadcast
        sends — unless its author has left the company (`message` says so).

        **Required Scopes:** `write:broadcasts`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  description: Optional reviewer note, stored on the audit entry.
                seen:
                  type: integer
                  description: |
                    Optional. The version of the message the caller read —
                    `content_version` as Unix seconds (`approval.content_version`). When
                    the item changed since, the decision is refused with 409
                    `content_changed`, so nobody approves a version they didn't see.
                    Omit it to decide without the check.
      responses:
        '200':
          description: Decision recorded
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ApprovalDecision"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot act on the current step (`forbidden`), the request
            was sent to the caller and their step was already decided (`no_longer_assigned`),
            Approvals is switched off (`approvals_disabled`), the token lacks the
            scope (`insufficient_permissions`), a Communications access policy limits
            the caller right now (`work_context_restricted`), or app not enabled (`app_disabled`)
        '404':
          description: No pending approval request with that id for this business
            (`not_found`)
        '409':
          description: The item changed since the `seen` version the caller read,
            or was edited after it was sent for review — the review then starts again
            on the new version (`content_changed`). Read it again, then decide
        '422':
          description: Decision could not be recorded (`approval_decision_failed`)
  "/approvals/{id}/reject":
    parameters:
    - name: id
      in: path
      required: true
      description: Communications approval request id (a source's `pending_approvals`
        item's `approval_id`, or `id` from GET /comms_hub/approval_requests).
      schema:
        type: integer
    post:
      tags:
      - Approvals
      security:
      - BearerAuth: []
      summary: Reject a pending approval request
      description: |
        Records the caller's **reject** decision on a pending Communications
        approval request — for a post, a broadcast, an alert or a newsletter
        issue — mirroring `Apps::CommsHub::ApprovalsController#reject`. Appends
        an entry to the audit trail and closes the request (status →
        `rejected`); a held post stays in its author's drafts and they are
        told why.

        **Required Scopes:** delete-level (owner decision 2026-10-04, #6721):
        on top of `write:broadcasts`, rejecting asks `destructive:safety_hub`
        for an alert and `destructive:broadcasts` for anything else — a post,
        a broadcast or a newsletter issue.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - notes
              properties:
                notes:
                  type: string
                  description: The reason — required; the author sees it and needs
                    to know what to change.
                seen:
                  type: integer
                  description: |
                    Optional. The version of the message the caller read —
                    `content_version` as Unix seconds (`approval.content_version`). When
                    the item changed since, the decision is refused with 409
                    `content_changed`, so nobody approves a version they didn't see.
                    Omit it to decide without the check.
      responses:
        '200':
          description: Decision recorded
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ApprovalDecision"
        '401':
          description: Authentication required
        '403':
          description: Caller cannot act on the current step (`forbidden`), the request
            was sent to the caller and their step was already decided (`no_longer_assigned`),
            Approvals is switched off (`approvals_disabled`), the token lacks the
            scope (`insufficient_permissions`), a Communications access policy limits
            the caller right now (`work_context_restricted`), or app not enabled (`app_disabled`)
        '404':
          description: No pending approval request with that id for this business
            (`not_found`)
        '409':
          description: The item changed since the `seen` version the caller read (`content_changed`)
            — read it again, then decide
        '422':
          description: No `notes` (`reason_required`), or the decision could not be
            recorded (`approval_decision_failed`)
  "/approvals/{id}/withdraw":
    parameters:
    - name: id
      in: path
      required: true
      description: Communications approval request id (a source's `pending_approvals`
        item's `approval_id`, or `id` from GET /comms_hub/approval_requests).
      schema:
        type: integer
    post:
      tags:
      - Approvals
      security:
      - BearerAuth: []
      summary: Withdraw a pending approval request
      description: |
        The person who sent a post, broadcast, alert or newsletter issue for
        approval takes it back while it waits, mirroring
        `Apps::CommsHub::ApprovalsController#withdraw`. The request closes as
        `withdrawn` (with an audit entry), reviewers' open Approve/Reject
        buttons retire, and the item is a draft again — a held post stays in
        drafts, a scheduled broadcast returns to drafts rather than sending, a
        newsletter issue leaves review. Only the requester or a company admin
        may withdraw, and only while the request is pending; while Approvals
        is switched off, only the requester.

        **Required Scopes:** delete-level (owner decision 2026-10-04, #6721):
        on top of `write:broadcasts`, withdrawing asks `destructive:safety_hub`
        for an alert and `destructive:broadcasts` for anything else.
      responses:
        '200':
          description: Request withdrawn
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ApprovalDecision"
        '401':
          description: Authentication required
        '403':
          description: Caller is neither the person who sent it for approval nor a
            company admin (`forbidden`), Approvals is switched off and the caller
            did not send it (`approvals_disabled`), the token lacks the scope (`insufficient_permissions`),
            a Communications access policy limits the caller right now (`work_context_restricted`),
            or app not enabled (`app_disabled`)
        '404':
          description: No pending approval request with that id for this business
            (`not_found` / `no_pending_approval`)
        '422':
          description: The request could not be withdrawn (`withdraw_failed`)
  "/approvals/{id}/remind":
    parameters:
    - name: id
      in: path
      required: true
      description: Communications approval request id (a My Posts row's `approval_request_id`,
        or `id` from GET /comms_hub/approval_requests).
      schema:
        type: integer
    post:
      tags:
      - Approvals
      security:
      - BearerAuth: []
      summary: Remind the approvers of a pending request
      description: |
        The person who sent a post, broadcast, alert or newsletter issue for
        approval reminds everyone who can decide its current step, mirroring
        the web request page's "Remind approvers"
        (`Apps::CommsHub::ApprovalsController#nudge`, one rule:
        `CommsHub::ApprovalNudge`). Only the requester, only while it is
        pending, only while approval notifications are on, and at most once a
        day — the reminder shares the 24-hour claim the automatic reminders
        use, and counts toward their cap. A My Posts row's `can_remind` says
        whether this will be taken.

        **Required Scopes:** `write:broadcasts`.
      responses:
        '202':
          description: Reminder sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  reached:
                    type: integer
                    description: How many reviewers it went to.
                  message:
                    type: string
                    example: Reminder sent to 2 reviewers.
                  next_remind_at:
                    type: string
                    format: date-time
                    description: When the next reminder may go.
                  approval:
                    "$ref": "#/components/schemas/Approval"
        '401':
          description: Authentication required
        '403':
          description: The caller did not ask for this approval (`forbidden`), Approvals
            is switched off (`approvals_disabled`), the token lacks the scope (`insufficient_permissions`),
            a Communications access policy limits the caller right now (`work_context_restricted`),
            or app not enabled (`app_disabled`)
        '404':
          description: No approval request with that id for this business (`not_found`),
            or it is no longer pending (`no_pending_approval`)
        '409':
          description: The communication was canceled (`comm_canceled`)
        '422':
          description: Approval notifications are turned off for this business (`notifications_off`),
            nobody holds the step so the reminder reached no one (`no_approvers`),
            or it could not be sent (`remind_failed`)
        '429':
          description: A reminder already went out in the last 24 hours (`already_reminded`);
            `error.details.next_remind_at` says when the next may go
  "/audience_options":
    get:
      tags:
      - Audience Options
      security:
      - BearerAuth: []
      summary: List audience-picker options (departments / locations / job titles
        / roles / groups / attribute dimensions)
      description: |
        Powers "Add to audience" in the mobile broadcast and alert composers. The
        `type` param selects which attribute to list; each item is
        `{ id, name, user_count }`:
          * `departments` / `locations` / `groups` → integer `id`
          * `roles` (admin / manager / member) / `job_titles` → string `id`
            (the role key / the title text)

        `attribute_dimensions` is the catalog behind "People who match all of
        several attributes" — the `attribute_match` audience `POST /feeds`
        accepts (`{"type":"attribute_match","match":{"<key>":[<id>, …]}}`). Each
        item is one attribute section, `{ key, label, kind, values:[{ id, name,
        chip?, group? }], own_attribute? }`, narrowed to what the caller may
        target (a manager sees no admin-only attribute; a Communications admin
        or contributor limited to places and departments sees only the places
        and departments inside that reach, as `departments` and `locations`
        do); it carries no
        `user_count`, and `q` filters the sections by label.

        `user_count` is the number of ACTIVE business users the entity resolves
        to, computed with the same logic as the audience resolver so the picker
        count matches the eventual reach. For parametric recipient groups whose
        membership varies per send, `user_count` is `null`. A department counts
        the people at or BENEATH it (Knowledge Layer X5 — "Store departments"
        includes Bakery's people), active accounts only, no service accounts.

        A Communications admin or contributor limited to places and departments
        (Knowledge Layer X1) is offered only the `departments` and `locations`
        inside that reach; the send itself is capped the same way. The list is
        narrowed when the client sends `reach=communications` (the broadcast
        composer), and always for a caller whose only way into this picker is
        that grant — an admin, a manager or a holder of the broadcasts `create`
        permission keeps the full list for the alert composer.

        Supports `q` (case-insensitive name/title search) and pagination. Mirrors
        the web BroadcastRecipientParametersController sources (e.g. roles are the
        canonical admin/manager/member keys — super_admin is excluded).
      parameters:
      - name: type
        in: query
        required: true
        description: Which attribute list to return.
        schema:
          type: string
          enum:
          - departments
          - locations
          - job_titles
          - roles
          - groups
          - attribute_dimensions
      - name: reach
        in: query
        required: false
        description: |
          `communications` — list only what the caller may send a broadcast to
          (a Communications admin or contributor limited to places and
          departments sees their reach).
        schema:
          type: string
          enum:
          - communications
      - name: q
        in: query
        required: false
        description: Case-insensitive search over the entity name / title.
        schema:
          type: string
      - name: page
        in: query
        required: false
        description: Page number (default 1).
        schema:
          type: integer
      - name: per_page
        in: query
        required: false
        description: Items per page (default 25, max 100).
        schema:
          type: integer
      responses:
        '200':
          description: Options listed for the requested type
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: A counted option; for `attribute_dimensions` an
                        attribute section instead (`key`, `label`, `kind`, `values`).
                      properties:
                        id:
                          description: Integer id (departments/locations/groups) or
                            string key (roles/job_titles).
                          oneOf:
                          - type: integer
                          - type: string
                        name:
                          type: string
                        user_count:
                          type: integer
                          nullable: true
                          description: Active users the entity resolves to; null for
                            parametric groups (membership varies).
                        key:
                          type: string
                          description: attribute_dimensions only — the key to use
                            in `match`.
                        label:
                          type: string
                          description: attribute_dimensions only — the section's heading.
                        kind:
                          type: string
                          description: attribute_dimensions only — enum / threshold
                            / band / person.
                        values:
                          type: array
                          description: attribute_dimensions only — the section's choices.
                          items:
                            type: object
                            properties:
                              id:
                                oneOf:
                                - type: integer
                                - type: string
                              name:
                                type: string
                              chip:
                                type: string
                              group:
                                type: string
                        own_attribute:
                          type: boolean
                          description: attribute_dimensions only — true when the section
                            is one attribute of its own (one custom field, one location
                            attribute) sharing its key with others; each is matched
                            AND with the rest.
                  meta:
                    type: object
                    required:
                    - type
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      type:
                        type: string
                        enum:
                        - departments
                        - locations
                        - job_titles
                        - roles
                        - groups
                        - attribute_dimensions
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Caller can't compose (not a manager/admin, broadcasts creator
            or Communications admin/contributor), or the app is not enabled
        '422':
          description: Missing or unknown `type` (`invalid_type`)
  "/links/resolve":
    get:
      tags:
      - Links
      security:
      - BearerAuth: []
      summary: Resolve a link to the phone page it opens, and whether the caller may
        open it
      description: |
        Answers from the same derivation the push payload and the inbox use
        (MobilePathConversion via DeepLinks::Allowlist), so a link opens the
        page its notification would.

          * `in_app` — the link has a phone page; `mobile_path` is that `/m/`
            page (open it in the shell's WebView).
          * `allowed` — `false` when the caller is certainly refused: the link
            belongs to another organization (`different_business`), its app is
            off or not visible to them (`app_unavailable`), or the item's read
            audience excludes them (`not_readable`). `true` otherwise — the page
            still applies its own checks. `null` for an external link.
          * `reason` — why `in_app` or `allowed` is false: `external`,
            `not_in_app` (open it in the in-app browser), `different_business`,
            `app_unavailable`, `not_readable`. `message` is the sentence to show.

        Never returns a title or any other field of the linked item; a missing
        item and one the caller cannot read answer the same `not_readable`.

        **Required Scopes:** `read:organization` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before the link is read. Every sign-in token holds it, and so does a
        token a person makes by hand from this deploy on; a token made by hand
        before this deploy may not. A browser session is asked no scope.
      parameters:
      - name: url
        in: query
        required: true
        description: An absolute http(s) URL, or a host-relative path ("/apps/…")
          resolved against the caller's organization. At most 2,048 characters.
        schema:
          type: string
      responses:
        '200':
          description: The link, resolved
          content:
            application/json:
              schema:
                type: object
                required:
                - link
                properties:
                  link:
                    type: object
                    required:
                    - url
                    - in_app
                    - mobile_path
                    - allowed
                    - reason
                    - message
                    properties:
                      url:
                        type: string
                      in_app:
                        type: boolean
                      mobile_path:
                        type: string
                        nullable: true
                        example: "/m/apps/policy-hub/policies/42"
                      allowed:
                        type: boolean
                        nullable: true
                      reason:
                        type: string
                        nullable: true
                        enum:
                        - external
                        - not_in_app
                        - different_business
                        - app_unavailable
                        - not_readable
                        -
                      message:
                        type: string
                        nullable: true
        '401':
          description: Authentication required
        '403':
          description: The token lacks `read:organization` (`insufficient_permissions`).
        '422':
          description: Not a link (`invalid_url`) — blank, too long, or neither an
            http(s) URL nor a "/" path
  "/frontline_execution/my_day":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The worker's filtered, paginated My Day list
      description: |
        Returns one page of the logged-in worker's My Day list for the selected
        filter, plus the exact count of all four filters on every call so a tab
        bar renders from a single request.

        My Day is **every** frontline user's own daily surface: there is no
        author-tier gate, so any authenticated caller — associate, store /
        district / regional manager, or head-office admin — reaches it and gets
        their own per-user list (the same as the web, where the My Day tab shows
        for every user). Access requires only the app being enabled and accessible
        to the caller and the My Day surface being on.

        This is the same ranked list the web My Day shows, so `assigned` includes
        both the obligations the worker holds AND the must-confirm acknowledgement
        "posts" they owe (a post has no single assignee — everyone in its audience
        must confirm it).

        **`filter`** selects one tab (all four are slices of that one list):

        | filter     | contains                                                        |
        |------------|-----------------------------------------------------------------|
        | `assigned` | the worker's whole My Day — held obligations + must-confirm posts (default) |
        | `overdue`  | those that are past due and still open                          |
        | `critical` | those whose campaign is marked critical (posts included)        |
        | `pool`     | the claim pool — unclaimed, role-matched work at the worker's sites |

        **`counts`** carries all four badges on every response (0 included), each
        the exact size of that filter's list — `overdue` and `critical` are
        subsets of `assigned`, so a row can count under more than one.

        Because the list includes posts (resolved in Ruby, not SQL), it is ranked
        and paged in memory over the ranker's bounded output: a page reaches at
        most the ranker's per-lane cap, exactly as the web list is capped.

        Rows are ordered by the same rank the worker sees on the web (overdue →
        HQ priority → soonest due → id), a total order so paging is stable across
        requests. `meta` is the pagination envelope for the SELECTED filter.

        An unrecognised `filter` (a typo, or an array value) is not an error: it
        defaults to `assigned` and is disclosed via `meta.filter_ignored` /
        `meta.filter_note`, and the applied filter is echoed back in `filter`, so
        a client never mistakes one tab's list for another's.

        Claim-pool rows are flagged `claimable: true` with a null `assignee`; a
        client renders a Claim action for them instead of Done/Release.
      parameters:
      - name: filter
        in: query
        required: false
        description: Which tab to return. Defaults to `assigned`.
        schema:
          type: string
          enum:
          - assigned
          - overdue
          - critical
          - pool
          default: assigned
      - "$ref": "#/components/parameters/Page"
      - name: limit
        in: query
        required: false
        description: Rows per page (1–100, default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The selected filter's page, all four counts, and pagination
            meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The filter actually rendered (the normalised `filter`).
                    enum:
                    - assigned
                    - overdue
                    - critical
                    - pool
                    example: assigned
                  counts:
                    type: object
                    description: Exact size of all four filters (0 included). Filters
                      overlap.
                    properties:
                      assigned:
                        type: integer
                        example: 7
                      overdue:
                        type: integer
                        example: 2
                      critical:
                        type: integer
                        example: 1
                      claim_pool:
                        type: integer
                        example: 3
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MyDayListItem"
                  meta:
                    "$ref": "#/components/schemas/MyDayListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            My Day surface is turned off (`surface_disabled`).
  "/frontline_execution/my_day/everything":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The worker's cross-app "Everything" list (My Day)
      description: |
        Returns one ranked, paginated list of everything the logged-in worker
        owes today across apps, plus the exact count of every filter lane so a
        tab bar renders from a single call.

        Like `my_day`, this is every frontline user's own surface: there is no
        author-tier gate, so any authenticated caller — associate, store /
        district / regional manager, or head-office admin — reaches it and gets
        their own per-user list. Access requires only the app being enabled and
        accessible to the caller and the My Day surface being on.

        **`filter`** selects one lane. `all` (the default) is the whole union;
        every other value is that one lane:

        | filter        | contains                                   |
        |---------------|--------------------------------------------|
        | `all`         | the whole deduped, ranked union            |
        | `frontline`   | Frontline Execution obligations            |
        | `inspections` | Inspections assigned to the worker         |
        | `posts`       | must-read messages awaiting acknowledgement|
        | `tasks`       | ordinary Tasks (not minted by Frontline)   |
        | `training`    | assigned/in-progress/overdue training      |
        | `schedule`    | shift offers addressed to the worker       |
        | `approvals`   | requests waiting on the worker's decision  |

        **`counts`** carries EVERY lane on every call (0 included), computed over
        the whole ranked union — so a lane's badge equals what paging that lane
        actually returns. `counts.all` is the union total.

        **`degraded_sources`** names any lane whose owning app failed to load
        (returned in the same product vocabulary as `filter`). A non-empty array
        means the list is INCOMPLETE — a client must say so rather than present a
        silently-short list as the whole day.

        **Bounded by design.** Each lane is capped server-side, so the union is a
        small, fixed size regardless of how much work the tenant has; the list is
        ranked and paged in memory because it spans seven models with no shared
        table or order key. Paging is stable across requests.

        An unrecognised `filter` (a typo, or an array value) is not an error: it
        defaults to `all` and is disclosed via `meta.filter_ignored` /
        `meta.filter_note`, so a client never mistakes the full list for a
        filtered one.
      parameters:
      - name: filter
        in: query
        required: false
        description: Which lane to return. Defaults to `all`.
        schema:
          type: string
          enum:
          - all
          - frontline
          - inspections
          - posts
          - tasks
          - training
          - schedule
          - approvals
          - onboarding
          - signatures
          default: all
      - "$ref": "#/components/parameters/Page"
      - name: limit
        in: query
        required: false
        description: Rows per page (1–100, default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The selected lane, every lane's count, and pagination meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The lane actually rendered (the normalised `filter`).
                    example: all
                  counts:
                    type: object
                    description: Exact size of every lane over the whole union (0
                      included).
                    properties:
                      all:
                        type: integer
                        example: 12
                      frontline:
                        type: integer
                        example: 4
                      inspections:
                        type: integer
                        example: 1
                      posts:
                        type: integer
                        example: 2
                      tasks:
                        type: integer
                        example: 3
                      training:
                        type: integer
                        example: 1
                      schedule:
                        type: integer
                        example: 0
                      approvals:
                        type: integer
                        example: 1
                  degraded_sources:
                    type: array
                    description: Lanes whose owning app failed to load, in `filter`
                      vocabulary. Non-empty ⇒ the list is incomplete.
                    items:
                      type: string
                      enum:
                      - frontline
                      - inspections
                      - posts
                      - tasks
                      - training
                      - schedule
                      - approvals
                      - onboarding
                      - signatures
                    example: []
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/UnifiedDayRow"
                  meta:
                    "$ref": "#/components/schemas/UnifiedDayListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            My Day surface is turned off (`surface_disabled`).
  "/frontline_execution/reviews":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The reviewer's queue of submitted work, with proof inline
      description: |
        Returns the submitted work waiting on this reviewer's decision, oldest
        first, span-bounded (an admin/app admin sees the whole tenant; a manager
        only their own stores). Each row carries the proof the reviewer decides
        on — photos, signature, completion note and the cached AI vision verdict.

        - **`items`** — one page of the queue (see `ReviewItem`).
        - **`campaign_id`** — the applied campaign filter, or `null`.
        - **`campaign_options`** — `{ id, name }` for every campaign with work in
          this reviewer's queue (plus the active filter), for the filter dropdown.
        - **`meta`** — the standard pagination envelope. When a `campaign_id` was
          sent that this business has no campaign for, `campaign_filter_ignored`
          is `true` and `campaign_filter_note` explains it (the queue is NOT
          silently widened).
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Page number (1-based).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (clamped 1–100).
      - name: campaign_id
        in: query
        required: false
        schema:
          type: integer
        description: Narrow the queue to one campaign. Tenant-checked; an unknown
          id is disclosed in `meta.campaign_filter_ignored` rather than widening.
      responses:
        '200':
          description: One page of the review queue for this caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign_id:
                    type: integer
                    nullable: true
                    description: The applied campaign filter, or null.
                  campaign_options:
                    type: array
                    description: Campaigns with work in this queue, for the filter
                      dropdown.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 42
                        name:
                          type: string
                          example: Endcap reset — March
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ReviewItem"
                  meta:
                    "$ref": "#/components/schemas/ReviewPaginationMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't review work (`forbidden`), or the My Day surface is turned
            off (`surface_disabled`).
  "/frontline_execution/reviews/ready_to_close":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Active campaigns whose work is fully resolved and ready to close
      description: |
        Returns the active campaigns this caller could close because every one
        of their obligations is resolved (done or missed) and none is still open,
        in progress or awaiting review. Span-bounded — an admin/app admin sees
        the whole tenant, a manager only campaigns confined to their own stores.

        - **`campaigns`** — the ready-to-close campaigns (see `ReadyToCloseCampaign`).
        - **`total_count`** — how many are returned.
        - **`capped`** — `true` when the list was cut at the 100-row cap.

        Unpaginated by design (a settled-work list, naturally small).
      responses:
        '200':
          description: The campaigns ready to close for this caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaigns:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ReadyToCloseCampaign"
                  total_count:
                    type: integer
                    example: 3
                  capped:
                    type: boolean
                    example: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't review/manage campaigns (`forbidden`), or the My Day surface
            is turned off (`surface_disabled`).
  "/frontline_execution/day_sheet":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The manager's Day Sheet — filtered, paginated to-dos with all eight
        counts
      description: |
        Returns one page of the Day Sheet for the selected `filter`, the exact
        size of ALL EIGHT filters (so the tab bar renders in one round-trip), and
        the standard pagination envelope for the selected filter.

        **Filters**

        - `everything` (default) — everything on the sheet that isn't finished
          (not done, not missed): the union of the five buckets below.
        - `escalated` — blocked work whose blocker group (campaign + reason) has
          an OPEN escalation. Overlaps `blocked`.
        - `blocked` — a worker reported a problem they can't clear.
        - `unassigned` — nobody is on the hook yet (open, in the pool).
        - `sent_back` — a reviewer sent it back to be redone (reopened).
        - `in_review` — proof is in, waiting on a reviewer (submitted).
        - `newly_assigned` — someone holds it and is working on it.
        - `completed` — done.

        Rows are ordered by triage rank (blocked → sent-back → unassigned →
        in-review → newly-assigned → completed) then soonest due then id — a
        total order, so paging is stable across requests.

        **Scoping & filters** — the sheet is span-bounded (an admin/app-admin
        sees the whole tenant, a manager only their own subtree). `location_id`
        narrows to one span-checked store (absent it, the whole span);
        `category_id` narrows to one programme. An out-of-span `location_id` is a
        `404`; an unknown `category_id` is disclosed via
        `meta.category_filter_ignored` rather than silently widening. An
        unrecognised `filter` defaults to `everything` and is disclosed via
        `meta.filter_ignored` / `meta.filter_note`.

        This is a **manager** surface — the same audience the web Day Sheet is
        gated to. A plain member is refused with `403 forbidden`.
      parameters:
      - name: filter
        in: query
        required: false
        description: Which tab to return. Defaults to `everything`.
        schema:
          type: string
          enum:
          - everything
          - escalated
          - blocked
          - unassigned
          - sent_back
          - in_review
          - newly_assigned
          - completed
          default: everything
      - name: location_id
        in: query
        required: false
        description: Narrow the sheet to one store (must be an active physical site
          inside the caller's span; `404` otherwise). Absent, the whole span.
        schema:
          type: integer
      - name: category_id
        in: query
        required: false
        description: Narrow the sheet to one campaign category (programme). An unknown
          id is ignored and disclosed in `meta.category_filter_ignored`.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: limit
        in: query
        required: false
        description: Rows per page (1–100, default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The selected filter's page, all eight counts, and pagination
            meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The filter actually rendered (the normalised `filter`).
                    enum:
                    - everything
                    - escalated
                    - blocked
                    - unassigned
                    - sent_back
                    - in_review
                    - newly_assigned
                    - completed
                    example: everything
                  location_id:
                    type: integer
                    nullable: true
                    description: The applied location filter, or null for the whole
                      span.
                    example: 3369
                  category_id:
                    type: integer
                    nullable: true
                    description: The applied category filter, or null.
                    example: 42
                  counts:
                    "$ref": "#/components/schemas/DaySheetCounts"
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DaySheetListItem"
                  meta:
                    "$ref": "#/components/schemas/DaySheetListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't manage the Day Sheet (`forbidden`), or the My Day surface
            is turned off (`surface_disabled`).
        '404':
          description: The requested `location_id` is not found or is outside the
            caller's area.
  "/frontline_execution/day_sheet/filters":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The Day Sheet's category and store filter options
      description: |
        Returns the two dropdowns the Day Sheet filters on:

        - **`categories`** — every campaign category in the business, ordered as
          the picker shows them (sort_order, then name). RETIRED (inactive)
          categories are included on purpose, flagged `active: false`, so a sheet
          narrowed to a since-retired programme still resolves. Identical for
          every caller who can reach the sheet.

        - **`locations`** — the active, physical stores this caller may open the
          sheet for, ordered by name, each as `{ id, name }`. Administrative
          rollup nodes (regions/divisions) and deactivated stores are excluded.
          This list is location-scoped: an admin / app admin sees every store in
          the tenant, a manager only the stores in their own subtree.

        - **`locations_meta`** — the store list's disclosure. `total` is the true
          store count in the caller's span; `shown` is how many are returned;
          `has_more` is `true` when the list was cut at the picker cap (2,000);
          `deactivated_only` is `true` when the span holds physical stores but
          every one is switched off (so a client shows "your stores are
          deactivated" rather than "no stores imported").

        This is a **manager** surface — the same audience the web Day Sheet is
        gated to. A plain member is refused with `403 forbidden`.
      responses:
        '200':
          description: The category and store filter options for this caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DaySheetCategory"
                  locations:
                    type: array
                    description: Active physical stores in the caller's span, ordered
                      by name.
                    items:
                      "$ref": "#/components/schemas/DaySheetStore"
                  locations_meta:
                    "$ref": "#/components/schemas/DaySheetLocationsMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            caller can't manage the Day Sheet (`forbidden`), or the My Day surface
            is turned off (`surface_disabled`).
  "/frontline_execution/day_sheet/what_changed":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: What changed at one store since a chosen point (the shift-change brief)
      description: |
        Returns the "what changed" brief for ONE store across a chosen look-back
        window — the same six buckets the web Day Sheet's "What changed" card
        renders, in the order a manager acts on them.

        **Buckets** (`changes`):

        | bucket           | what it holds                                            |
        |------------------|----------------------------------------------------------|
        | `blocked`        | work flagged "I can't do this" in the window (with reason)|
        | `sent_back`      | work reopened for rework in the window                   |
        | `escalated`      | work escalated in the window (distinct items, not events)|
        | `newly_assigned` | work that landed on somebody in the window               |
        | `new_in_pool`    | unclaimed work that appeared in the claim pool           |
        | `completed`      | work finished in the window                              |

        Each bucket is capped at `bucket_limit` (10) and SAYS SO when it hits the
        cap via `summary.<bucket>.capped` — a truncated list is never presented as
        complete. `completed` and `escalated` also carry a true `total` (the two
        counted buckets), so a client can render "showing the 10 most recent of N".

        **`since_hours`** is snapped to the offered windows (4/8/12/24/48/72),
        defaulting to 12. An unrecognised value defaults to 12 and is disclosed via
        `window.since_hours_ignored` / `window.since_hours_note`, so a client never
        renders a 12-hour brief under a label it did not ask for. `window.options`
        advertises the pickable windows so a client can render the selector.

        **`location_id` is required** — the brief is per-store. A missing param is
        `422 invalid_request`; a store that is closed, an administrative (rollup)
        node, outside the caller's span, or gone is `404 not_found` (never a silent
        widening to the whole span).

        `any_changes` is false when nothing moved in the window — distinct from a
        store with no work at all. Rows are titled by campaign name and carry the
        current holder (null for claim-pool rows).
      parameters:
      - name: location_id
        in: query
        required: true
        description: The store to brief on. Must be an active physical site inside
          the caller's span.
        schema:
          type: integer
        example: 8
      - name: since_hours
        in: query
        required: false
        description: Look-back window in hours. Snapped to one of 4/8/12/24/48/72;
          defaults to 12.
        schema:
          type: integer
          enum:
          - 4
          - 8
          - 12
          - 24
          - 48
          - 72
          default: 12
      responses:
        '200':
          description: The store's what-changed brief for the selected window.
          content:
            application/json:
              schema:
                type: object
                properties:
                  location:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 8
                      name:
                        type: string
                        example: Fourth Street
                  window:
                    "$ref": "#/components/schemas/ShiftBriefWindow"
                  any_changes:
                    type: boolean
                    description: Whether anything moved in the window (distinct from
                      "no work at all").
                    example: true
                  bucket_limit:
                    type: integer
                    description: The per-bucket render cap; a bucket at this size
                      may hold more.
                    example: 10
                  summary:
                    type: object
                    description: Count + cap state per bucket. `completed`/`escalated`
                      also carry a true `total` past the cap.
                    additionalProperties:
                      "$ref": "#/components/schemas/ShiftBriefBucketSummary"
                    example:
                      newly_assigned:
                        count: 3
                        capped: false
                      new_in_pool:
                        count: 1
                        capped: false
                      blocked:
                        count: 2
                        capped: false
                      sent_back:
                        count: 0
                        capped: false
                      completed:
                        count: 10
                        capped: true
                        total: 43
                      escalated:
                        count: 1
                        capped: false
                        total: 1
                  changes:
                    type: object
                    description: The six expandable lists, in the order the card reads
                      them.
                    properties:
                      blocked:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefBlockedRow"
                      sent_back:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      escalated:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      newly_assigned:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      new_in_pool:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefRow"
                      completed:
                        type: array
                        items:
                          "$ref": "#/components/schemas/ShiftBriefCompletedRow"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller is not a manager (`forbidden`), the caller lacks the required
            scope (`insufficient_permissions`), or the My Day surface is turned off
            (`surface_disabled`).
        '404':
          description: The store was not found or is outside the caller's span (`not_found`)
            — closed, an administrative node, or in another tenant.
        '422':
          description: "`location_id` was not supplied (`invalid_request`)."
  "/frontline_execution/passdown":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Read the current shift pass-down for a location
      description: |
        Returns the handover the incoming shift should read, or `null` when
        nothing was handed over inside the currency window (24h).

        **`passdown.status` matters.** A `machine_closed` record means the last
        shift was ended by the system — auto clock-out, a punch-device sync, a
        CSV import — so nobody was ever prompted for a handover. Clients MUST
        render that differently from `passdown: null`: the first means "nobody
        was asked", the second means "nothing was handed over". Showing a blank
        all-clear card for a machine-closed shift is the failure this endpoint
        exists to prevent.
      parameters:
      - name: location_id
        in: query
        required: true
        schema:
          type: integer
        description: A physical location inside the caller's span.
      responses:
        '200':
          description: The current pass-down, or null.
          content:
            application/json:
              schema:
                type: object
                properties:
                  location_id:
                    type: integer
                    example: 1118
                  passdown:
                    oneOf:
                    - "$ref": "#/components/schemas/ShiftPassdown"
                    - type: 'null'
                  also_from_this_shift:
                    type: array
                    description: 'Earlier handovers from the same shift nobody has
                      read yet, newest first (two closers each left one); empty when
                      there are none. Show them with `passdown`: acknowledging `passdown`
                      with their ids in `also_ids` marks them read too, and each one
                      whose `acknowledgeable` is true can be acknowledged on its own.'
                    items:
                      "$ref": "#/components/schemas/ShiftPassdown"
        '403':
          description: Pass-downs are turned off for this business.
        '404':
          description: Location not found or outside the caller's span.
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Record a shift pass-down
      description: |
        Files a handover against a location. The newest pass-down supersedes
        earlier ones for that site; previous records remain as history.

        A submission with neither `notes` nor any `structured` value is refused
        with `422 empty` — an empty handover tells the next shift nothing, and
        recording one would let a site look covered when it is not.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - location_id
              properties:
                location_id:
                  type: integer
                  example: 1118
                notes:
                  type: string
                  example: Bed 12 family conversation still open. Cold chain checked
                    at 6pm.
                structured:
                  type: object
                  additionalProperties:
                    type: string
                  description: Free key/value counts. The instrument differs per tenant
                    and per site.
                  example:
                    headcount: '14'
                    open_items: '2'
      responses:
        '200':
          description: Pass-down recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  passdown:
                    "$ref": "#/components/schemas/ShiftPassdown"
        '403':
          description: Pass-downs are turned off for this business.
        '404':
          description: Location not found or outside the caller's span.
        '422':
          description: location_id missing, empty submission, or the record failed
            validation.
  "/frontline_execution/passdown/{id}/acknowledge":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Acknowledge a shift pass-down
      description: |
        Records that the incoming shift has read the handover. Refused for the
        handover's author — they are not the next shift (offer the action only
        where the record's `acknowledgeable` is true). A second call on an already-acknowledged
        record answers 409 and leaves the original reader and timestamp intact.
        Acknowledging also marks read the earlier unread handovers from the same
        shift that the client names in `also_ids` — the `also_from_this_shift`
        rows it showed beside this one. Without `also_ids` only this handover
        is acknowledged: a row the reader was never shown is never marked read.
        An id that is not an earlier unread handover from this one's shift, or
        one the caller may not acknowledge, is skipped.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                also_ids:
                  type: array
                  items:
                    type: integer
                  description: Ids of the `also_from_this_shift` rows shown with this
                    handover.
                  example:
                  - 41
      responses:
        '200':
          description: Acknowledged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  passdown:
                    "$ref": "#/components/schemas/ShiftPassdown"
                  also_acknowledged:
                    type: array
                    description: The `also_ids` rows acknowledged with it; empty when
                      none were.
                    items:
                      "$ref": "#/components/schemas/ShiftPassdown"
        '403':
          description: Pass-downs are turned off for this business.
        '404':
          description: Handover not found or outside the caller's span.
        '409':
          description: Already acknowledged (`already_acknowledged`).
        '422':
          description: |
            Refused — `own_handover` (the caller wrote it), `own_shift` (the caller worked a
            shift at this store that ended within 30 minutes of it — the handover is for
            the next shift) or `invalid`.
  "/frontline_execution/items/{id}":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: One obligation in full (adapts to the caller's lens)
      description: |
        Returns everything the web detail and proof surfaces show for one
        obligation, adapted to who is asking:

        - **`item`** — the common core (identity, badges, requirements,
          instruction, documents, holder/accountable, blocker/send-back context,
          challenge), plus lens-gated sections:
          - `assignment_history` and `review_proof` — for a **reviewer** (a
            manager/admin whose span covers the location). `review_proof` is
            present only for work sitting in `submitted`.
          - `capture` and `assignment_explanation` — for the **holder** (or an
            acknowledgement roster member): what's already captured, the shared
            note trail, and why the work landed on them.
        - **`viewer`** — the caller's lens (`is_holder` / `is_reviewer`) and a
          `can` map of the six item actions they may take (mirrors the web's own
          "show this button" truth; the write endpoints remain the authority).
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) id.
        schema:
          type: integer
      responses:
        '200':
          description: The obligation, with the sections the caller is entitled to.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/FrontlineItemDetail"
                  viewer:
                    "$ref": "#/components/schemas/FrontlineItemViewer"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            My Day surface is turned off (`surface_disabled`), or the caller can't
            see this row (`forbidden`).
        '404':
          description: No such obligation in this business (`not_found`).
  "/frontline_execution/requests":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The Requests tab — the caller's asks, or the approval queue, filtered
        and paged
      description: |
        Returns one page of intake requests for the selected `filter`, plus every
        visible tab's count and the caller's review capability.

        - **`filter`** — the APPLIED filter (`all` / `submitted` / `approved` /
          `declined` / `approval`). Differs from the requested one when that was
          unknown or not available to the caller — see `meta.filter_ignored`.
        - **`can_review`** — whether the caller is a campaign-author gatekeeper (so
          the client draws the "For approval" tab and reads `counts.approval`).
        - **`counts`** — every visible tab's badge in one round-trip. `all` /
          `submitted` / `approved` / `declined` count the caller's OWN asks;
          `approval` (reviewers only) counts every pending ask in the business.
        - **`requests`** — one page of rows. Each row is the full request (same
          shape as the detail endpoint) plus a `viewer` block whose flags mirror
          the web buttons (`can_withdraw` on the caller's own pending ask;
          `can_approve` / `can_decline` for a gatekeeper on a pending, non-engine
          request).
        - **`meta`** — the standard pagination envelope. When the requested filter
          was not applied, `filter_ignored` is `true` and `filter_note` says why
          (a typo, or `approval` asked for by a non-reviewer) — the list is NOT
          silently widened; it falls back to `all`.
      parameters:
      - name: filter
        in: query
        required: false
        description: 'Which lens to read: `all` (default), `submitted` ("In review"),
          `approved`, `declined` — the caller''s own asks — or `approval`, the reviewer-only
          queue of everyone''s pending asks.'
        schema:
          type: string
          enum:
          - all
          - approval
          - submitted
          - approved
          - declined
          default: all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Page number (1-based).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (clamped 1–100).
      responses:
        '200':
          description: One page of requests for the selected filter, with counts and
            the caller's capability.
          content:
            application/json:
              schema:
                type: object
                properties:
                  filter:
                    type: string
                    description: The applied filter (may differ from the requested
                      one — see meta).
                    example: all
                  can_review:
                    type: boolean
                    description: The caller is a campaign-author gatekeeper (the "For
                      approval" tab is theirs).
                    example: false
                  counts:
                    "$ref": "#/components/schemas/FrontlineRequestCounts"
                  requests:
                    type: array
                    items:
                      "$ref": "#/components/schemas/FrontlineRequestListRow"
                  meta:
                    "$ref": "#/components/schemas/FrontlineRequestListMeta"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            Campaigns surface is turned off (`surface_disabled`).
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Submit an intake request (the "New request" form)
      description: |
        Submits a campaign-intake request — the API twin of the web "New request"
        form every persona reaches from the Requests tab. EVERY member may file one
        ("stores can ask too"), so this rides the own-scoped write tier with no
        author gate; only the app + Campaigns-surface toggles apply.

        The request routes through `Execution::CampaignRequestCreator`, the SAME
        writer the web form uses, so a phone submission and a browser submission are
        identical: cross-tenant FK guards on department/audience/category, store-list
        resolution (multi-select ids INTERSECTED with the tenant's physical sites,
        plus pasted/uploaded store numbers), out-of-band attachment, and decision
        routing (into the tenant's configured approval workflow when one exists, else
        a notification to the gatekeepers).

        On success returns **201** with the created request in the same
        `{request, viewer}` shape the detail endpoint returns, plus:
        - **`store_list`** — how many pasted store numbers matched and which didn't
          (present only when a store list was pasted).
        - **`warnings`** — a file the request couldn't attach (reported, not fatal;
          the ask itself is saved), present only when there is something to say.

        A validation failure is **422** whose `error.details.errors` names the
        offending fields.

        Send `application/json` for a plain request, or `multipart/form-data` when
        attaching files (`attachments[]`) or uploading a store-number CSV
        (`store_list_paste_file`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/FrontlineRequestCreateBody"
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/FrontlineRequestCreateBody"
              - type: object
                properties:
                  attachments:
                    type: array
                    description: Reference files for the ask (images/PDFs/docs; oversized
                      or unsupported files are reported in `warnings`, not fatal).
                    items:
                      type: string
                      format: binary
                  store_list_paste_file:
                    type: string
                    format: binary
                    description: A CSV/text file of store numbers, resolved the same
                      way as `store_list_paste`.
      responses:
        '201':
          description: The request was submitted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      target_locations:
                        type: array
                        description: The stores the requester named directly, resolved
                          to id + name and ordered by name. Empty when the request
                          names an audience or every location instead of a direct
                          list. Tenant-scoped — a store id from another business is
                          never surfaced here.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 42
                            name:
                              type: string
                              example: Store 042 - Downtown
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
                  store_list:
                    "$ref": "#/components/schemas/FrontlineRequestStoreListOutcome"
                  warnings:
                    type: array
                    description: Non-fatal notices (e.g. a file that couldn't be attached).
                      Present only when non-empty.
                    items:
                      type: string
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), or the
            Campaigns surface is turned off (`surface_disabled`).
        '422':
          description: The request was refused (`invalid_request`). `error.details.errors`
            maps each offending field to its messages. `invalid_from_request` — the
            `from_request_id` is not a declined request of the caller's.
  "/locations":
    get:
      tags:
      - Locations
      security:
      - BearerAuth: []
      summary: List the caller's assigned, active work locations
      description: |
        The locations the authenticated user is assigned to in the current business,
        active ones only, ordered by name — never the business's hidden Virtual
        Location, which stands in for "no location" on a shift (#11123). `is_primary` marks the user's primary
        location (at most one). Geofence fields are present only when the location
        enforces one; `allowed_radius_meters` is null otherwise.

        This is the caller's OWN list — an admin sees their own assignments here too,
        not the tenant's directory.

        **Required Scopes:** `read:organization` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made by
        hand before this deploy may not. A browser session is asked no scope.
      responses:
        '200':
          description: Assigned locations
          content:
            application/json:
              schema:
                type: object
                required:
                - locations
                - meta
                properties:
                  locations:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AssignedLocation"
                  meta:
                    type: object
                    required:
                    - total
                    properties:
                      total:
                        type: integer
                        description: Number of locations in `locations`.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — the token lacks `read:organization` (`insufficient_permissions`),
            or the token's business context is missing.
  "/locations/{id}/departments":
    get:
      tags:
      - Locations
      summary: List a location's active departments
      description: |
        The active departments of one location in the caller's business,
        ordered by name — what a shift form offers once a location is picked.
        Inactive departments are left out, so every row's `active` is `true`.

        The location may be any location of the business — not only the
        caller's own assigned locations — and inactive locations are not
        refused. A location id from another business, or one that does not
        exist, answers 404. The list is not paginated.

        **Required Scopes:** `read:organization`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made
        by hand before it may not. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The location's id.
        schema:
          type: integer
      responses:
        '200':
          description: The location's active departments, A to Z
          content:
            application/json:
              schema:
                type: object
                required:
                - departments
                properties:
                  departments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LocationDepartmentsDepartment"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:organization`
            ("Required scope: read:organization").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            `not_found` — no location with this id in the caller's business
            ("Location not found").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: |
            `internal_server_error` — the department list could not be built
            ("Could not fetch departments").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recipient_group_parameters/locations":
    get:
      tags:
      - Recipient Group Parameters
      summary: List the locations a recipient-group picker may offer
      description: |
        Every active, physical location of the caller's business, A to Z, as
        `{ id, name }` — the choices for a group whose `needed_for` answer
        includes a `location` parameter. The system Virtual Location (where
        location-less team shifts are stored) is never offered. The answer is
        a bare JSON array, not paginated.

        **Required Scopes:** `read:organization`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made
        by hand before it may not. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The business's active, physical locations, A to Z
          content:
            application/json:
              schema:
                type: array
                items:
                  "$ref": "#/components/schemas/RecipientGroupParametersOption"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:organization`
            ("Required scope: read:organization").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` — the list could not be built ("Could
            not fetch locations").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recipient_group_parameters/departments":
    get:
      tags:
      - Recipient Group Parameters
      summary: List the departments a recipient-group picker may offer
      description: |
        Every organizational department of the caller's business, A to Z, as
        `{ id, name }` — the choices for a group whose `needed_for` answer
        includes a `department` parameter. These are the company's
        organizational departments (not a location's floors and units, which
        are GET /locations/{id}/departments). No active filter is applied, so
        an inactive department is listed too. The answer is a bare JSON array,
        not paginated.

        **Required Scopes:** `read:organization`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made
        by hand before it may not. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The business's organizational departments, A to Z
          content:
            application/json:
              schema:
                type: array
                items:
                  "$ref": "#/components/schemas/RecipientGroupParametersOption"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:organization`
            ("Required scope: read:organization").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` — the list could not be built ("Could
            not fetch departments").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recipient_group_parameters/employees":
    get:
      tags:
      - Recipient Group Parameters
      summary: Search the people a recipient-group picker may offer
      description: |
        Members of the caller's business with an active membership, for a
        group whose `needed_for` answer includes an `affected_criteria`
        parameter (who is affected). Service accounts (Ask AI, System) are
        never listed. Ordered by first name, then last name, and capped at 50
        rows — a typeahead, not a directory: pass `q` to narrow it. Without
        `q` the first 50 alphabetically are returned. The answer is a bare
        JSON array, not paginated.

        **Who may search:** someone who can send communications, as in the
        web composer — the Communications app must be open to the caller,
        and the caller must be an admin or manager of the business, hold the
        broadcasts `create` permission, or be a Communications communicator
        (the must-read permission, or a Communications app admin or
        contributor, company-wide or for some places and departments).
        Anyone else gets `403 forbidden`; a business without Communications
        gets `403 app_disabled`, and a caller the app is not opened to
        `403 app_unavailable`.

        **Required Scopes:** `read:people_directory` (the people directory's
        own scope; admin-grade `read:users` satisfies it too). A token without
        it gets `403 insufficient_permissions` ("Required scope:
        read:people_directory") before anything else is checked. A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Case-insensitive substring matched against first name, last name,
          email, "first last" and "preferred-name last". Surrounding spaces are trimmed;
          blank means no filter.
        schema:
          type: string
      responses:
        '200':
          description: Up to 50 matching members
          content:
            application/json:
              schema:
                type: array
                maxItems: 50
                items:
                  "$ref": "#/components/schemas/RecipientGroupParametersEmployee"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:people_directory` ("Required scope: read:people_directory").
            - `app_disabled` — "The Broadcasts app is not enabled for this business.": the business has not turned Communications on.
            - `app_unavailable` — "The Broadcasts app is not available for your account.": Communications is on, but not opened to the caller.
            - `work_context_restricted` — the business's work-context policy for Communications blocks the caller right now (off shift, off site or quiet hours); `error` is `{ code: work_context_restricted, message }`, with `code` repeated at the top level and `error_detail` repeating `{ code, message }`.
            - `forbidden` — "Only someone who can send communications can choose people for a group.": the caller is not a composer.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` — the list could not be built ("Could
            not fetch employees").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recipient_group_parameters/needed_for/{id}":
    get:
      tags:
      - Recipient Group Parameters
      summary: Which parameters a recipient group needs before sending
      description: |
        The parameters the sender must supply before the recipient group can
        resolve to people. An empty `parameters` array means the group
        resolves on its own and needs nothing.

        The group may be a system-wide group or one owned by the caller's
        business. An id that does not exist, or that belongs to another
        business, is NOT a 404: it answers `200 { "parameters": [] }`.

        Which group needs what, by its recipient type and key:
        - a `location` group whose key names `location_staff` /
          `location_managers` needs `location` (fill from
          /recipient_group_parameters/locations);
        - a `role` group whose key names `department_heads` needs
          `department` (fill from /recipient_group_parameters/departments);
        - an `affected` group whose key names `affected_employees` needs
          `affected_criteria` (fill from /recipient_group_parameters/employees).

        The first two are skipped when the group's own criteria already pin
        its members (fixed users, roles, locations, departments, statuses,
        employee type, tenure, schedules or title); the `affected_employees`
        group always asks. Every other group needs nothing.

        **Who may ask:** someone who can send communications, the same rule as
        GET /recipient_group_parameters/employees — the Communications app
        open to the caller, and an admin or manager, the broadcasts `create`
        permission or a Communications communicator. Anyone else gets
        `403 forbidden`; a business without Communications gets
        `403 app_disabled`, and a caller the app is not opened to
        `403 app_unavailable`.

        **Required Scopes:** `read:organization`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:organization")
        before anything else is checked. Every sign-in token holds it, and so
        does a token a person makes by hand from this deploy on; a token made
        by hand before it may not. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recipient group's id.
        schema:
          type: integer
      responses:
        '200':
          description: The parameters the group needs (empty when none, or when the
            group is not found)
          content:
            application/json:
              schema:
                type: object
                required:
                - parameters
                properties:
                  parameters:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecipientGroupParametersRequiredParameter"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:organization` ("Required scope: read:organization").
            - `app_disabled` — "The Broadcasts app is not enabled for this business.": the business has not turned Communications on.
            - `app_unavailable` — "The Broadcasts app is not available for your account.": Communications is on, but not opened to the caller.
            - `work_context_restricted` — the business's work-context policy for Communications blocks the caller right now (off shift, off site or quiet hours); `error` is `{ code: work_context_restricted, message }`, with `code` repeated at the top level and `error_detail` repeating `{ code, message }`.
            - `forbidden` — "Only someone who can send communications can choose people for a group.": the caller is not a composer.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` — the answer could not be worked out
            ("Could not determine parameters").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/practice-hub/scenarios":
    get:
      tags:
      - Practice Hub
      summary: List the published practice scenarios
      description: |
        The business's published practice scenarios — the Practice Library —
        paginated. Micro-drills and call-derived coach drills (scenarios
        tagged `drill`) are never listed, and neither are drafts, scenarios in
        review or archived ones. Only scenarios owned by the caller's business
        are listed.

        Ordered trusted-first: `official`, then `curated`, then `community`
        visibility; then higher average rating, more attempts, most recently
        updated, and finally newest id — so paging is stable.

        **Required Scopes:** `read:practice_hub`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:practice_hub")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/PracticeHubPage"
      - "$ref": "#/components/parameters/PracticeHubPerPage"
      responses:
        '200':
          description: One page of published scenarios
          content:
            application/json:
              schema:
                type: object
                required:
                - scenarios
                - meta
                properties:
                  scenarios:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PracticeHubScenario"
                  meta:
                    "$ref": "#/components/schemas/PracticeHubPaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:practice_hub` ("Required scope: read:practice_hub").
            - `app_disabled` — Practice Hub is not enabled for the business ("Practice Hub is not enabled for this business.").
            - `app_forbidden` — Practice Hub is not published to the caller ("You don't have access to Practice Hub.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/practice-hub/attempts":
    get:
      tags:
      - Practice Hub
      summary: List the caller's own practice attempts
      description: |
        The caller's own practice attempts in their business, newest first
        (`created_at` descending, then id), paginated. Nobody else's attempts
        are listed here — a manager or admin sees only their own too.

        `score` is the scorecard's final score: a reviewer's overridden score
        when one was set, otherwise the AI's overall score; null until the
        attempt is scored.

        **Required Scopes:** `read:practice_hub`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:practice_hub")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/PracticeHubPage"
      - "$ref": "#/components/parameters/PracticeHubPerPage"
      responses:
        '200':
          description: One page of the caller's attempts
          content:
            application/json:
              schema:
                type: object
                required:
                - attempts
                - meta
                properties:
                  attempts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PracticeHubAttempt"
                  meta:
                    "$ref": "#/components/schemas/PracticeHubPaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:practice_hub` ("Required scope: read:practice_hub").
            - `app_disabled` — Practice Hub is not enabled for the business ("Practice Hub is not enabled for this business.").
            - `app_forbidden` — Practice Hub is not published to the caller ("You don't have access to Practice Hub.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/muscle-memory/lessons":
    get:
      tags:
      - Muscle Memory
      summary: List or search the business's lessons
      description: |
        The business's lessons, never including sample lessons and never
        including forgotten ones (forgetting is permanent, so there is no list
        of them). Any member the app is published to can read them; the
        lessons describe operations, never people.

        **What is listed.** With no `status`, the active lessons — verified and
        unverified together. `status=unverified` is the review queue (active
        lessons nobody has confirmed yet), `status=verified` the confirmed
        active ones, and `status=superseded` the lessons the system superseded
        because later evidence stopped supporting them. A superseded lesson
        stays visible but no longer informs Ask AI answers; these are the
        candidates for `POST …/{id}/reinstate`. `status=recheck` (curators
        only) is the app's "Due for a re-check" list: hand-written lessons
        confirmed more than six months ago and not used since — confirm one
        again (`POST …/{id}/verify`) to renew it; a caller who may not curate
        gets `403 forbidden`. `status` takes exactly those four values — there
        is no `all` (omit the parameter) and no `forgotten`.

        **Two shapes, decided by `q`.**
        - No `q` (or a blank one): a paginated list. The response carries
          `meta` with `page`, `per_page`, `total` and `total_pages`. A `page`
          past the end returns the last page, so read `meta.page` for the page
          you got.
        - With `q`: a search, unpaginated and capped. The response carries
          `count` (the rows returned, not the number of matches), `per_page`
          (the cap that applied) and `truncated` (true when more lessons match
          than were returned — `page` does not reach them; narrow the query
          or filters instead). It carries no `meta`. `q` is cut to 200
          characters. A search with no `status` uses the same recall Ask AI
          reads — meaning plus keywords, active lessons at or above the
          business's confidence floor only, in relevance order, so an
          unverified suggestion (born below the floor) is never found that
          way. Add `status` (for example `status=unverified&q=dock`) to
          search the review queue: that keyword search covers the lessons
          that status lists and honours `sort`.

        **Filters are refused, not ignored.** An unknown `status` is `400
        invalid_status`; a `domain` that is neither a platform domain nor one
        this business's lessons carry is `400 invalid_domain`. Both name the
        accepted values in `error.details.allowed`. A recognised domain with no
        lessons is an empty `200`. An unknown `sort` is not an error: it falls
        back to `relevance`.

        **Required Scopes:** `read:muscle_memory`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:muscle_memory")
        before anything else is checked.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: 'Narrow the list to one lifecycle state: `unverified` (active,
          not yet confirmed — the review queue), `verified` (active and confirmed),
          `superseded` (later evidence stopped supporting them; still listed, out
          of answers) or `recheck` (curators only — confirmed over six months ago
          and not used since; see `due_for_recheck`). Omit it for every active lesson.
          Exact lowercase match; anything else, including `all`, `forgotten` and `Verified`,
          is `400 invalid_status`.'
        schema:
          type: string
          enum:
          - unverified
          - verified
          - superseded
          - recheck
      - name: domain
        in: query
        required: false
        description: Only lessons in this operational domain. Exact lowercase match
          against the platform's domains (`safety_hub`, `scheduling`, `service_desk`,
          `leave_management`, `onboarding`, `rfp_desk`, `automation`, `operations`,
          `frontline_execution`, `procurement`, `timesheets`, `forms`, `asset_pro`,
          `mango_spend`, `employee_data`, `contracts`) plus any other domain this
          business's own lessons carry. An unrecognised value is `400 invalid_domain`;
          its `error.details.allowed` lists what this business accepts.
        schema:
          type: string
      - name: q
        in: query
        required: false
        description: Search text, at most 200 characters (longer is cut, not refused).
          Switches the response to the unpaginated search shape described above. Blank
          or whitespace-only means no search.
        schema:
          type: string
          maxLength: 200
      - name: sort
        in: query
        required: false
        description: 'Order of the paginated list and of a status-filtered search:
          `relevance` (default — most recently offered to an answer first, then highest
          confidence), `confidence` (highest first) or `newest` (most recently learned
          first). An unrecognised value means `relevance`. A search with no `status`
          keeps the recall''s own relevance order and ignores `sort`.'
        schema:
          type: string
          enum:
          - relevance
          - confidence
          - newest
          default: relevance
      - name: page
        in: query
        required: false
        description: Page number, from 1, for the paginated list only (a search ignores
          it). A missing, non-numeric or non-positive value means page 1; a page past
          the end returns the last page.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: 'Without `q`, rows per page: default 25, at most 100. With `q`,
          the cap on rows returned: default 10, at most 50. A missing, zero, negative
          or non-numeric value means the default; a larger value is lowered to the
          maximum.'
        schema:
          type: integer
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of lessons (with `meta`), or — when `q` was given
            — the search result (with `count`, `per_page` and `truncated`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MuscleMemoryLessonList"
        '400':
          description: |
            A filter value the endpoint does not recognise. `error.code` says which:
            - `invalid_status` — `status` is not `unverified`, `verified`, `superseded` or `recheck`. For `forgotten` the message adds that forgotten lessons are not listed anywhere.
            - `invalid_domain` — `domain` is neither a platform domain nor one this business's lessons carry.

            `error.details` is `{ "field": "status" | "domain", "allowed": [...] }`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: invalid_status
                  message: 'Can''t filter lessons by "forgotten". Use one of: unverified,
                    verified, superseded, recheck. Forgotten lessons aren''t listed
                    on any surface — forgetting is permanent, so there is no list
                    of them to return.'
                  details:
                    field: status
                    allowed:
                    - unverified
                    - verified
                    - superseded
                    - recheck
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryReadForbidden"
    post:
      tags:
      - Muscle Memory
      summary: Add a lesson (born verified)
      description: |
        Adds one lesson to the business's memory, as a curator. The lesson is
        **born verified** at confidence 0.8, above the confidence floor, so it
        counts straight away — and informs Ask AI answers wherever the business
        has recall switched on. Only a caller who holds the curate capability
        may add one this way; a member who does not gets `403 forbidden`
        (whose message, when the business lets them suggest lessons, points at
        the app, where they can suggest one for a curator to review — this API
        has no suggest endpoint).

        Send one plain sentence as `lesson_text`. An AI step rewrites it into a
        single house-style claim, chooses the domain and a location when the
        text names one, so the `claim` in the response is usually not your
        exact wording; this runs on the request and can take several seconds.
        If the AI step is unavailable the text is stored as written, under the
        `operations` domain.

        A sentence that restates a lesson the business already holds is not
        stored twice: the existing lesson is reinforced and the caller is
        recorded as having confirmed it, and the response is `200` with
        `outcome: reinforced` and that existing lesson. A new lesson is `201`
        with `outcome: created`.

        Text that looks like a credential or access code, and text content
        moderation refuses, is rejected (`sensitive`, `content_moderation`).

        **Required Scopes:** `write:muscle_memory`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - lesson_text
              properties:
                lesson_text:
                  type: string
                  minLength: 15
                  maxLength: 600
                  description: The lesson, as one plain sentence — what the business
                    knows to be true. Between 15 and 600 characters once surrounding
                    whitespace is trimmed. A list or an object is refused (`invalid_parameter`),
                    never turned into text.
            example:
              lesson_text: Dock 4 floods in heavy rain, so inbound trailers are rerouted
                to dock 2.
      responses:
        '201':
          description: A new lesson was created.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MuscleMemoryCreateResult"
        '200':
          description: 'The sentence matched a lesson the business already holds,
            so that lesson was reinforced and re-confirmed instead of duplicated (`outcome:
            reinforced`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MuscleMemoryCreateResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryCurateForbidden"
        '422':
          description: |
            The sentence was refused. `error.code` says why and `error.details.field` is `lesson_text`:
            - `invalid_parameter` — `lesson_text` was a list or an object, not a single text value.
            - `invalid_lesson` — the text could not be read as one plain sentence.
            - `invalid_length` — shorter than 15 or longer than 600 characters (a missing `lesson_text` lands here).
            - `sensitive` — it looks like a password, an account number or an access code; nothing was stored.
            - `content_moderation` — the business's content moderation refused it.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          "$ref": "#/components/responses/MuscleMemoryInternalError"
  "/muscle-memory/stats":
    get:
      tags:
      - Muscle Memory
      summary: Read the aggregate learning picture
      description: |
        The same figures as the app's Analytics tab: how many lessons the
        business holds, how they split by domain and by the distiller that
        learned them, how much is being recalled into answers, the measured
        lift on lessons that intervened, and how reliably the playbooks they
        graduate into complete. Team totals only — nothing per person. Sample
        lessons are not counted.

        This is its own capability, not the curate one: the caller must hold
        the "Who can see Muscle Memory analytics?" capability (managers and
        admins by default; the business can widen it to everyone). A member who
        lacks it gets `403 forbidden` here and can still read the lessons
        themselves.

        `total`, `verified`, `unverified`, `avg_confidence` and `by_domain`
        count **active** lessons only; `superseded` and `forgotten` are their
        own counts.

        **Required Scopes:** `read:muscle_memory`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The aggregate statistics.
          content:
            application/json:
              schema:
                type: object
                required:
                - stats
                properties:
                  stats:
                    "$ref": "#/components/schemas/MuscleMemoryStats"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:muscle_memory` ("Required scope: read:muscle_memory").
            - `app_disabled` — Muscle Memory is not enabled for the business ("Muscle Memory is not enabled for this business.").
            - `forbidden` — Muscle Memory is not published to the caller ("You don't have access to Muscle Memory."), or the caller lacks the analytics capability ("You don't have access to the aggregate memory statistics for this business. You can still read the lessons themselves.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/muscle-memory/lessons/{id}":
    get:
      tags:
      - Muscle Memory
      summary: Read one lesson with its influence history
      description: |
        One lesson, including `influence_history` — the most recent times it
        was actually used in an answer (at most five): when, on which surface,
        for which agent and domain. The list does not carry the history; this
        read does.

        Readable are active and superseded lessons. A forgotten lesson, a
        sample lesson, an id from another business and an id that does not
        exist all answer the same `404 not_found`.

        `influence_history` is empty for every lesson when the business has
        turned "Keep a history of where each lesson was used" off — the lesson's
        `recall_audit_enabled` field says which case you are looking at. The
        `user` key (who received the answer) appears only for a caller who is
        a manager or above, and only when the business has also turned on
        recording who received the answer (off by default); otherwise the key
        is absent.

        **Required Scopes:** `read:muscle_memory`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MuscleMemoryLessonId"
      responses:
        '200':
          description: The lesson and its influence history.
          content:
            application/json:
              schema:
                type: object
                required:
                - lesson
                properties:
                  lesson:
                    "$ref": "#/components/schemas/MuscleMemoryLessonDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryReadForbidden"
        '404':
          "$ref": "#/components/responses/MuscleMemoryLessonNotFound"
    delete:
      tags:
      - Muscle Memory
      summary: Forget a lesson (permanent)
      description: |
        **Forgets the lesson, permanently.** Its status becomes `forgotten`:
        it disappears from every list and read on every surface, stops
        informing Ask AI answers, and the learning jobs never learn it again.
        Nothing can bring it back — `reinstate` refuses it (`not_superseded`),
        `verify` and `correct` refuse it (`forgotten`), and no screen or call
        restores it. To have the lesson back, add it again with
        `POST /muscle-memory/lessons`. The row and its usage receipts are kept
        for the record (this is not a hard delete), but no API read returns
        them.

        The `200` response is the last time the lesson is returned: its
        `status` is `forgotten`. When a person wrote the lesson (or their
        suggestion was merged into it) they are told it was forgotten, and the
        open "review this lesson" requests curators were holding for it are
        closed. Repeating the call on a lesson that is already forgotten changes
        nothing and tells no one again: it answers `200` with the same status,
        `already_forgotten: true` and a `notice` saying so.

        Needs the curate capability, and — because forgetting is a final act —
        the `destructive:muscle_memory` scope, not `write:muscle_memory`.

        **Required Scopes:** `destructive:muscle_memory`. A
        `write:muscle_memory` token gets `403 insufficient_permissions`
        ("Required scope: destructive:muscle_memory").
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MuscleMemoryLessonId"
      responses:
        '200':
          description: 'The lesson, now forgotten (`status: forgotten`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MuscleMemoryCurationResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryCurateForbidden"
        '404':
          "$ref": "#/components/responses/MuscleMemoryLessonNotFound"
        '500':
          "$ref": "#/components/responses/MuscleMemoryInternalError"
  "/muscle-memory/lessons/{id}/verify":
    post:
      tags:
      - Muscle Memory
      summary: Confirm a lesson is true
      description: |
        Records that the caller reviewed the lesson and it holds: the caller
        becomes `verified_by`, `verified_at` is set to now, and the lesson's
        confidence rises by 0.1 (to at most 1.0). An unverified suggestion
        that was below the confidence floor starts informing answers once it
        clears it. On this first confirmation, when a person wrote the lesson
        (or their suggestion was merged into it) they are told it was
        confirmed — not while it is superseded. The open review requests
        curators were holding for it are closed — except on a superseded
        lesson, whose question stays open.

        **Confirming again.** On a lesson that is `due_for_recheck` it renews
        the check (`verified_by` and `verified_at` move to the caller and now)
        and leaves confidence where it is. On a lesson already confirmed and
        not due for a re-check it changes nothing: the response is `200` with
        `already_verified: true` and a `notice` saying so, so a retry after a
        timeout is safe.

        **A superseded lesson stays superseded.** Confirming one nobody has
        confirmed yet records the review but does not bring it back: the
        response is `200`, the lesson's `status` is still `superseded`, it
        stays out of answers, and the body carries a `notice` saying so. One
        already confirmed records this review too (`verified_by` and
        `verified_at` move to the caller; confidence is unchanged). Use
        `POST …/{id}/reinstate` to bring it back. A first confirmation of an
        active lesson carries no `notice`.

        A forgotten lesson cannot be confirmed (`422 forgotten`).

        Needs the curate capability.

        **Required Scopes:** `write:muscle_memory`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MuscleMemoryLessonId"
      responses:
        '200':
          description: The lesson after the review. When it is superseded the response
            also carries a `notice`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MuscleMemoryCurationResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryCurateForbidden"
        '404':
          "$ref": "#/components/responses/MuscleMemoryLessonNotFound"
        '422':
          description: "- `forgotten` — the lesson was forgotten, so there is nothing
            to confirm; a forgotten lesson cannot be restored.\n"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          "$ref": "#/components/responses/MuscleMemoryInternalError"
  "/muscle-memory/lessons/{id}/correct":
    post:
      tags:
      - Muscle Memory
      summary: Correct a lesson with a note
      description: |
        Attaches a correction `note` to the lesson and records the caller as
        having reviewed it. **A `note` is required.** The lesson's `claim` is
        not rewritten: the note is kept beside it as `correction_note` and
        travels with the lesson wherever it is shown or exported. The caller
        becomes `verified_by`, `verified_at` is set to now, and confidence is
        lifted to at least the business's confidence floor (it is never
        lowered). A later correction replaces the earlier note. When a person
        wrote the lesson (or their suggestion was merged into it) they are told
        it was corrected, and the open review requests curators were holding
        for it are closed.

        **A superseded lesson stays superseded.** Correcting it saves the note
        but does not bring the lesson back: the response is `200`, `status` is
        still `superseded`, and the body carries a `notice` saying so. Use
        `POST …/{id}/reinstate` to bring it back.

        The note is held to the same rules as a lesson: trimmed, not blank,
        not a credential or access code, at most 2,000 characters, and
        content moderation applies.

        Needs the curate capability.

        **Required Scopes:** `write:muscle_memory`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MuscleMemoryLessonId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note
              properties:
                note:
                  type: string
                  maxLength: 2000
                  description: The correction, as plain text. Required — a missing
                    or blank note is `422 note_required`. A list or an object is `422
                    invalid_parameter`; more than 2,000 characters is `422 invalid`.
            example:
              note: Dock 4 now drains within the hour; reroute only after a full day
                of rain.
      responses:
        '200':
          description: The lesson with its correction note. When it is superseded
            the response also carries a `notice`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MuscleMemoryCurationResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryCurateForbidden"
        '404':
          "$ref": "#/components/responses/MuscleMemoryLessonNotFound"
        '422':
          description: |
            The correction was refused; nothing changed. `error.code` says why:
            - `invalid_parameter` — `note` was a list or an object (`error.details.field` is `note`).
            - `note_required` — `note` was missing or blank.
            - `sensitive` — the note looks like a password, an account number or an access code.
            - `content_moderation` — the business's content moderation refused it.
            - `invalid` — the note is longer than 2,000 characters.
            - `forgotten` — the lesson was forgotten, so there is nothing to correct; a forgotten lesson cannot be restored.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          "$ref": "#/components/responses/MuscleMemoryInternalError"
  "/muscle-memory/lessons/{id}/reinstate":
    post:
      tags:
      - Muscle Memory
      summary: Reinstate a superseded lesson ("Still holds")
      description: |
        A curator attests that a **superseded** lesson still holds. It goes
        back to `active`, its confidence is lifted back above the business's
        confidence floor so it re-enters Ask AI recall, the caller becomes
        `verified_by`, and the marks that superseded it are cleared. The open
        "this lesson may no longer hold" requests curators were holding for it
        are closed.

        **Only a superseded lesson can be reinstated.** On an active lesson
        the call is refused (`422 not_superseded`) rather than reported as a
        success that changed nothing; on a forgotten lesson it is refused the
        same way — reinstating cannot undo a forget. Find candidates with
        `GET /muscle-memory/lessons?status=superseded`.

        Needs the curate capability.

        **Required Scopes:** `write:muscle_memory`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MuscleMemoryLessonId"
      responses:
        '200':
          description: The lesson, now active.
          content:
            application/json:
              schema:
                type: object
                required:
                - lesson
                properties:
                  lesson:
                    "$ref": "#/components/schemas/MuscleMemoryLesson"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/MuscleMemoryCurateForbidden"
        '404':
          "$ref": "#/components/responses/MuscleMemoryLessonNotFound"
        '422':
          description: '- `not_superseded` — "That lesson isn''t superseded, so there''s
            nothing to reinstate." The lesson is active or forgotten; nothing changed.

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          "$ref": "#/components/responses/MuscleMemoryInternalError"
  "/asset_pro/assets/{id}":
    get:
      tags:
      - Asset Pro
      summary: Get one asset
      description: |
        One asset of the caller's business, with its category, holder,
        location and service-ticket counters.

        Who may read it:
        - the person the asset is assigned to, whatever their role;
        - an Asset Pro admin — a role in the tenant's Asset Pro admin roles,
          an app admin of Asset Pro, or a person Asset Pro settings name as
          one who may maintain vendors, contracts and categories;
        - a person in the tenant's Asset Pro manager roles, or named in Asset
          Pro settings as one who may manage assets, for an asset held by
          someone in their reporting chain (direct or indirect report).

        Anyone else gets 403 "Access denied", even with the app open to them.

        **Required Scopes:** `read:asset_pro`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:asset_pro")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The asset's id.
        schema:
          type: integer
      responses:
        '200':
          description: The asset
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - asset
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  asset:
                    "$ref": "#/components/schemas/AssetProAssetDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            Two body shapes.

            The error envelope (`{ "error": { "code", "message" } }`):
            - `insufficient_permissions` — the token lacks `read:asset_pro` ("Required scope: read:asset_pro").

            A plain `{ "success": false, "message": "…" }` body:
            - "AssetPro is not enabled for your organization" — Asset Pro is not enabled for the business (or not configured on this install).
            - "You don't have access to AssetPro" — Asset Pro is not published to the caller.
            - "Access denied" — the caller is not the asset's holder, an Asset Pro admin, or a manager of its holder.
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/Error"
                - "$ref": "#/components/schemas/AssetProFailure"
        '404':
          description: |
            No asset with this id in the caller's business. Plain body:
            `{ "success": false, "message": "Asset not found" }`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AssetProFailure"
  "/appointment_scheduler/booking_pages":
    get:
      tags:
      - Appointment Scheduler
      summary: List the caller's booking pages
      description: |
        The booking pages the caller owns or hosts on as a team member, newest
        first (`created_at` descending), paginated. An Appointment Scheduler
        admin gets every booking page in the business; each row's
        `relationship` says how the caller reaches it.

        `bookable` folds in the business's **Allow booking via public /meet
        pages** setting, so it is never `true` for a link that would answer
        "unavailable"; `public_booking_disabled` says when that setting is the
        reason.

        **Required Scopes:** `read:appointment_scheduler`. A token without it
        gets `403 insufficient_permissions` ("Required scope:
        read:appointment_scheduler") before anything else is checked.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        description: Pages per result page. Clamped to 1..100; a blank value means
          50. `limit` is accepted as an alias.
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of the caller's booking pages
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking_pages
                - meta
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking_pages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AppointmentSchedulerBookingPage"
                  meta:
                    "$ref": "#/components/schemas/AppointmentSchedulerPaginationMeta"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerForbidden"
  "/appointment_scheduler/appointments":
    get:
      tags:
      - Appointment Scheduler
      summary: List appointments the caller can read
      description: |
        The appointments the caller may read — ones they host, ones booked on
        a page they own (round-robin, collective and group bookings on a team
        page often land on a teammate), and collective meetings they attend as
        a recorded co-host. An Appointment Scheduler admin reads every
        appointment in the business. Each row's `can_reschedule` and
        `can_cancel` say whether the caller may move or cancel it.

        `filter`:
        - `upcoming` (the default, and what any other value means) —
          confirmed appointments that have not ended yet, so a meeting in
          progress is included. Earliest start first.
        - `past` — confirmed and no-show appointments that have ended. Latest
          start first.
        - `cancelled` — cancelled appointments, and the originals a reschedule
          replaced (status `rescheduled`). Most recently changed first.

        **Required Scopes:** `read:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        schema:
          type: string
          enum:
          - upcoming
          - past
          - cancelled
          default: upcoming
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        description: Appointments per page. Clamped to 1..100; a blank value means
          25. `limit` is accepted as an alias.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of appointments
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - filter
                - appointments
                - meta
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  filter:
                    type: string
                    enum:
                    - upcoming
                    - past
                    - cancelled
                    description: The filter applied.
                  appointments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AppointmentSchedulerAppointment"
                  meta:
                    "$ref": "#/components/schemas/AppointmentSchedulerPaginationMeta"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerForbidden"
  "/appointment_scheduler/appointments/{id}":
    get:
      tags:
      - Appointment Scheduler
      summary: Get one appointment
      description: |
        One appointment the caller may read, with the invitee's intake
        answers, their time zone, the cancellation reason, the ten most
        recent history entries, and the bookings a move links it to.

        **Required Scopes:** `read:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AppointmentSchedulerAppointmentId"
      responses:
        '200':
          description: The appointment
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - appointment
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  appointment:
                    "$ref": "#/components/schemas/AppointmentSchedulerAppointmentDetail"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerForbidden"
        '404':
          description: '"Appointment not found" — no such appointment, or not one
            the caller may read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
  "/appointment_scheduler/availability":
    get:
      tags:
      - Appointment Scheduler
      summary: Preview a booking page's availability
      description: |
        What an invitee would be offered on one of the caller's booking pages
        (one they own or host on as a team member; admins: any page), for ONE
        meeting type — a page's meeting types have different durations and so
        offer different times.

        Without `date`, the dates in the meeting type's booking window that
        have at least one offerable slot (`available_dates`). With `date`, the
        offerable slots that day (`date` and `slots`). `unbookable_reason` is
        `no_host` when none of the page's hosts is still an active member of
        the business — the reason a scan comes back empty — and null otherwise.

        **Required Scopes:** `read:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - name: booking_page_id
        in: query
        required: true
        schema:
          type: integer
      - name: appointment_type_id
        in: query
        description: An active meeting type on that page. Defaults to the page's first
          active meeting type.
        schema:
          type: integer
      - name: date
        in: query
        description: A day (YYYY-MM-DD) on the booking page's calendar.
        schema:
          type: string
          format: date
      responses:
        '200':
          description: The page's availability for that meeting type
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking_page
                - meeting_type
                - timezone
                - unbookable_reason
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking_page:
                    type: object
                    required:
                    - id
                    - title
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                  meeting_type:
                    type: object
                    required:
                    - id
                    - name
                    - duration_minutes
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      duration_minutes:
                        type: integer
                  timezone:
                    type: string
                    description: The booking page's IANA time zone.
                  unbookable_reason:
                    type: string
                    nullable: true
                    enum:
                    - no_host
                    -
                  date:
                    type: string
                    format: date
                    description: Present when `date` was sent.
                  slots:
                    type: array
                    description: Present when `date` was sent.
                    items:
                      "$ref": "#/components/schemas/AppointmentSchedulerSlot"
                  available_dates:
                    type: array
                    description: Present when no `date` was sent.
                    items:
                      type: string
                      format: date
        '400':
          description: |
            - "booking_page_id is required"
            - "Invalid date — use YYYY-MM-DD"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerForbidden"
        '404':
          description: '"Booking page not found" — no such page, or not one the caller
            owns or hosts on (admins: any page).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '422':
          description: '"No active meeting type found for this booking page" — the
            page has none, or `appointment_type_id` is not an active meeting type
            on it.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
  "/appointment_scheduler/appointments/{id}/reschedule_slots":
    get:
      tags:
      - Appointment Scheduler
      summary: Times an appointment can move to
      description: |
        The availability a move of this appointment can use: its own booking
        page and meeting type, with the appointment itself left out of the
        meeting type's daily cap. Without `date`, the dates with at least one
        offerable slot (`available_dates`); with `date`, that day's slots.
        Send a returned `starts_at` to `POST .../reschedule`.

        Only for an appointment the caller may change (its host, its booking
        page's owner, or an admin) that is confirmed and has not started.

        **Required Scopes:** `read:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AppointmentSchedulerAppointmentId"
      - name: date
        in: query
        description: A day (YYYY-MM-DD) on the booking page's calendar.
        schema:
          type: string
          format: date
      responses:
        '200':
          description: Where the appointment can move
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - timezone
                - unbookable_reason
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  timezone:
                    type: string
                    description: The booking page's IANA time zone.
                  unbookable_reason:
                    type: string
                    nullable: true
                    enum:
                    - no_host
                    -
                    description: "`no_host` when none of the page's hosts is still
                      an active member — why the result is empty."
                  date:
                    type: string
                    format: date
                  slots:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AppointmentSchedulerSlot"
                  available_dates:
                    type: array
                    items:
                      type: string
                      format: date
        '400':
          description: '"Invalid date — use YYYY-MM-DD"'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerCannotChange"
        '404':
          description: '"Appointment not found" — no such appointment, or not one
            the caller may read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '422':
          description: '"This appointment can''t be rescheduled" — it is not confirmed,
            or it has already started.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
  "/appointment_scheduler/appointments/{id}/cancel":
    post:
      tags:
      - Appointment Scheduler
      summary: Cancel an appointment
      description: |
        Cancels a confirmed appointment that has not started, as its host.
        The calendar event is removed, any reserved room is released, and the
        invitee is normally emailed a cancellation (not for a sample-data
        booking). The other hosts and participants are told as the business's
        notification settings say.

        A repeat of a cancel that already landed — a retry after a timeout —
        answers 200 with `already_applied: true`; nothing more is cancelled,
        sent or recorded, and its `reason` is not stored.

        **Required Scopes:** `destructive:appointment_scheduler`. A
        `write:appointment_scheduler` token is not enough.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AppointmentSchedulerAppointmentId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
                  description: Optional reason, shown to the invitee. Longer than
                    500 characters is refused (422 `invalid`), never clipped.
      responses:
        '200':
          description: Cancelled (or already cancelled)
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - already_applied
                - appointment
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  already_applied:
                    type: boolean
                  appointment:
                    "$ref": "#/components/schemas/AppointmentSchedulerAppointment"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerCannotChange"
        '404':
          description: '"Appointment not found" — no such appointment, or not one
            the caller may read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '422':
          description: |
            Refused by the booking service; `code` says why:
            - `not_cancellable` — it is no longer confirmed, or it has started.
            - `not_found` — it was deleted while the request ran.
            - `invalid` — the save failed, e.g. a `reason` over 500 characters.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
  "/appointment_scheduler/appointments/{id}/no_show":
    post:
      tags:
      - Appointment Scheduler
      summary: Mark an appointment a no-show
      description: |
        Records that the invitee didn't come, as the appointment's host, its
        booking page's owner or an Appointment Scheduler admin. Only a past,
        confirmed appointment can be marked. Bookkeeping only: no email is
        sent and the calendar is not changed, and a follow-up email not yet
        sent (it goes about 12 hours after a meeting) is skipped.

        A repeat of a mark that already landed answers 200 with
        `already_applied: true`. `DELETE` on the same path clears the mark.

        **Required Scopes:** `write:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AppointmentSchedulerAppointmentId"
      responses:
        '200':
          description: Marked a no-show (or already marked)
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - already_applied
                - appointment
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  already_applied:
                    type: boolean
                  appointment:
                    "$ref": "#/components/schemas/AppointmentSchedulerAppointment"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerCannotChange"
        '404':
          description: '"Appointment not found" — no such appointment, or not one
            the caller may read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '422':
          description: "`code: not_markable` — it has not ended yet, or it is not
            confirmed (cancelled or moved)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
    delete:
      tags:
      - Appointment Scheduler
      summary: Clear an appointment's no-show mark
      description: |
        Returns a no-show to confirmed, as its host, its booking page's owner
        or an Appointment Scheduler admin. If the meeting ended within the
        last 3 days and its follow-up email was not sent, the follow-up can
        then go out. Anything not marked a no-show — including a repeat of a
        clear that already landed — answers 422 `not_marked`.

        **Required Scopes:** `write:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AppointmentSchedulerAppointmentId"
      responses:
        '200':
          description: Cleared
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - already_applied
                - appointment
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  already_applied:
                    type: boolean
                    enum:
                    - false
                  appointment:
                    "$ref": "#/components/schemas/AppointmentSchedulerAppointment"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerCannotChange"
        '404':
          description: '"Appointment not found" — no such appointment, or not one
            the caller may read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '422':
          description: "`code: not_marked` — the appointment isn't marked a no-show."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
  "/appointment_scheduler/appointments/{id}/reschedule":
    post:
      tags:
      - Appointment Scheduler
      summary: Move an appointment to a new time
      description: |
        Moves a confirmed appointment that has not started to a new start
        time, as its host. The new time must be a slot the booking page
        offers for that meeting type (see `reschedule_slots`). A replacement
        appointment is created and returned; the original keeps status
        `rescheduled` and its id is `rescheduled_from_id`. On a round-robin or
        collective page the replacement can go to a different host.

        The invitee is normally emailed the new time (not for a sample-data
        booking) — the business decides whether that is a dedicated "moved"
        email or a plain confirmation — and the entry already on their
        calendar is updated in place.

        A repeat of a move that already landed at this time answers 200 with
        `already_applied: true` and the replacement it made; nothing more is
        booked or sent.

        **Required Scopes:** `write:appointment_scheduler`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/AppointmentSchedulerAppointmentId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - starts_at
              properties:
                starts_at:
                  type: string
                  format: date-time
                  description: The new start, ISO 8601 with its UTC offset, on a date
                    that exists — e.g. `2026-09-15T13:00:00Z` or `2026-09-15T09:00:00-04:00`.
                    A time without an offset is refused.
      responses:
        '200':
          description: Moved (or already moved to this time)
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - already_applied
                - rescheduled_from_id
                - appointment
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  already_applied:
                    type: boolean
                  rescheduled_from_id:
                    type: integer
                    description: The appointment that was moved (the id in the path).
                  appointment:
                    "$ref": "#/components/schemas/AppointmentSchedulerAppointment"
        '400':
          description: |
            - "starts_at (ISO 8601) is required"
            - "starts_at must be an ISO 8601 time with its UTC offset, on a date that exists — e.g. 2026-09-15T13:00:00Z or 2026-09-15T09:00:00-04:00"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '401':
          "$ref": "#/components/responses/AppointmentSchedulerUnauthenticated"
        '403':
          "$ref": "#/components/responses/AppointmentSchedulerCannotChange"
        '404':
          description: '"Appointment not found" — no such appointment, or not one
            the caller may read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
        '422':
          description: |
            Refused by the booking service; `code` says why:
            - `slot_taken` — the new time is not offerable (taken, outside availability, or past a limit); pick another slot and retry.
            - `not_cancellable` — it is no longer confirmed, or it has started.
            - `not_found` — it was deleted while the request ran.
            - `invalid` — the replacement failed to save.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
  "/bookings/bookings":
    get:
      tags:
      - Bookings
      summary: List bookings
      description: |
        The bookings the caller may read in their business, newest start first
        (`start_time` descending, then `id` descending), paginated. Every
        status is listed unless `status` narrows it.

        Which bookings are listed is the tenant's "Who can see everyone's
        bookings?" policy (Bookings → Settings → User Permissions; default:
        managers): a caller it admits sees every booking in
        the business; on the `managers` policy a person with direct reports
        also sees their reports' bookings; everyone else sees only their own.

        Filters:
        - `status` — one status (`?status=confirmed`) or several
          (`?status[]=confirmed&status[]=completed`). A hash-shaped value is
          ignored. An unknown status matches nothing.
        - `start_date` — bookings starting on or after that calendar day, read
          on each booking's room's clock (its own time zone, else the
          workspace's).
        - `end_date` — bookings ending on or before the end of that calendar day,
          on the same clock.
          An unparseable date is ignored (no filter), never a 400.

        The response also carries `X-Total-Count`, `X-Total-Pages`,
        `X-Current-Page` and `X-Per-Page` headers.

        **Required Scopes:** `read:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:bookings")
        before anything else is checked. The app's sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        description: Bookings per page. A numeric value is clamped to 1..100; a blank
          or non-numeric value means 25.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: Only bookings in this status. Repeat as `status[]=` for several.
        schema:
          type: string
          enum:
          - confirmed
          - cancelled
          - completed
          - no_show
      - name: start_date
        in: query
        description: Only bookings whose `start_time` is on or after this calendar
          day, on each booking's room's clock.
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: Only bookings whose `end_time` is on or before the end of this
          calendar day, on each booking's room's clock.
        schema:
          type: string
          format: date
      responses:
        '200':
          description: One page of the bookings the caller may read
          headers:
            X-Total-Count:
              description: Bookings matching the filters, across all pages.
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - bookings
                - meta
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  bookings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BookingsBookingListItem"
                  meta:
                    "$ref": "#/components/schemas/BookingsPaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:bookings` ("Required scope: read:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business ("Bookings is not enabled").
            - `app_not_accessible` — the caller is not an admin or Bookings app admin and the app is not published to them ("Access denied").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — the Bookings app is not configured on this install."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Bookings
      summary: Book a space
      description: |
        Books a space for the caller. The booking is always the caller's own
        (`user` is the token's person), is created `confirmed`, and gets a
        generated `booking_reference` (`API<business id><YYYYMMDD><8 hex>`).

        The tenant's rules apply as on the web: "Who Can Book" (403
        `booking_not_permitted`), the booking policy (daily cap, advance
        window, bookable hours, minimum / maximum duration), space types the
        tenant switched off, the space's capacity, no booking in the past, and
        no overlap with another confirmed booking of the same space ("Space is
        not available during the selected time"). Each is answered `422
        validation_failed` with the sentence in `message` and every sentence in
        `details.messages`. A `space_id` that is not one of this business's
        spaces is dropped, so the save fails "Space can't be blank".

        Times: a naive time is read in `time_entry_zone` when it names a valid
        time zone, else on the chosen space's clock. On success the holder is
        sent the confirmation (mail with a calendar invite and an in-app
        notification), as the web door does.

        **Required Scopes:** `write:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:bookings")
        before anything else is checked. The app's sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/BookingsBookingWrite"
            example:
              booking:
                space_id: 42
                start_time: 2026-10-14T14:00
                end_time: 2026-10-14T15:00
                purpose: Planning
                attendee_count: 4
      responses:
        '201':
          description: The booking was made
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking:
                    "$ref": "#/components/schemas/BookingsBookingCreated"
        '400':
          description: "`parameter_missing` — the body has no `booking` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:bookings` ("Required scope: write:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
            - `booking_not_permitted` — the tenant's "Who Can Book" policy does not include the caller ("Booking permissions required.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — the Bookings app is not configured on this install."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`validation_failed` — the booking could not be saved; `details.messages`
            lists every reason."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
  "/bookings/bookings/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The booking's id.
      schema:
        type: integer
    get:
      tags:
      - Bookings
      summary: Get a booking
      description: |
        One booking, with its notes, check-in times, cost and space location.
        Found only among the bookings the caller may read (the same rule as
        the list); any other id answers `404 not_found` ("Booking not found").

        **Required Scopes:** `read:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:bookings")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The booking
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking:
                    "$ref": "#/components/schemas/BookingsBookingDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:bookings` ("Required scope: read:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no booking with this id that the caller may
            read ("Booking not found"), or the Bookings app is not configured on this
            install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Bookings
      summary: Change a booking
      description: |
        Changes a booking's space, times, purpose, notes, attendee count or
        attendees. Send only the fields to change, inside `booking`.

        Who may: the holder, or someone the tenant's "Who can see everyone's
        bookings?" policy lets act on the holder (on the default `managers`
        policy, the holder's own manager) — anyone else gets `403 forbidden`
        ("Not authorized"). The caller must also pass "Who Can Book" (403
        `booking_not_permitted`, checked before the booking is looked up).

        The record's own rules: a booking that is not `confirmed`, or has
        started, keeps every field (`422 not_editable`, with the reason —
        cancelled, ended or started), as on the booking page and the phone.
        The space can no longer be changed (`422 space_locked`) and the times
        can no longer be changed (`422 time_locked`) once the booking is
        checked in or starts within the tenant's modification window (default
        2 hours). The booking policy is then re-run on the
        changed booking (daily cap, advance window, bookable hours, overlap)
        and a failure answers `422 validation_failed`.

        A naive time is read in `time_entry_zone` when valid, else on the clock
        of the space the booking moves to (or its current space). When the
        time or the space actually changed, the holder is sent the corrected
        calendar invite; a purpose-only change sends nothing.

        **Required Scopes:** `write:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:bookings")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/BookingsBookingWrite"
            example:
              booking:
                purpose: Quarterly planning
      responses:
        '200':
          description: The booking was changed
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking:
                    "$ref": "#/components/schemas/BookingsBookingUpdated"
        '400':
          description: "`parameter_missing` — the body has no `booking` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:bookings` ("Required scope: write:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
            - `booking_not_permitted` — the tenant's "Who Can Book" policy does not include the caller.
            - `forbidden` — the caller may read the booking but may not change it ("Not authorized").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no booking with this id that the caller may
            read ("Booking not found"), or the Bookings app is not configured on this
            install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `not_editable` — the booking was cancelled, has ended or has started ("This booking has ended, so its details can no longer be changed.").
            - `space_locked` — "This booking's space can no longer be changed."
            - `time_locked` — "This booking's time can no longer be changed."
            - `validation_failed` — the changed booking could not be saved; `details.messages` lists every reason.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
    put:
      tags:
      - Bookings
      summary: Change a booking (PUT)
      description: |
        Same as PATCH: the same action, rules, body and responses. Send only
        the fields to change, inside `booking`.

        **Required Scopes:** `write:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:bookings").
        A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/BookingsBookingWrite"
      responses:
        '200':
          description: The booking was changed
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking:
                    "$ref": "#/components/schemas/BookingsBookingUpdated"
        '400':
          description: "`parameter_missing` — the body has no `booking` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:bookings`.
            - `app_not_enabled`, `app_not_accessible`, `booking_not_permitted`, `forbidden` — as for PATCH.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — no booking with this id that the caller may
            read."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`not_editable`, `space_locked`, `time_locked` or `validation_failed`
            — as for PATCH."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
    delete:
      tags:
      - Bookings
      summary: Delete a booking
      description: |
        Deletes the booking outright — the row is removed and the space is
        freed. To release a booking but keep it in the holder's history (and
        in the utilization and cancellation counts), use
        `POST /bookings/bookings/{id}/cancel` instead.

        Who may: the holder, or someone the tenant's "Who can see everyone's
        bookings?" policy lets act on the holder; anyone else gets `403
        forbidden` ("Not authorized"). Any status may be deleted, but not
        inside the tenant's cancellation window (default 2 hours before the
        start) — which also covers every booking that has already started —
        answered `422 inside_cancellation_window` ("Bookings can only be
        deleted N hours in advance."). A walk-up hold made on a room display
        has no window.

        The holder is sent the cancellation (which withdraws the calendar
        invite), and a "booking cancelled" entry naming who deleted it is
        written to the app's activity trail.

        **Required Scopes:** `destructive:bookings` — `write:bookings` is not
        enough. A token without it gets `403 insufficient_permissions`
        ("Required scope: destructive:bookings") before anything else is
        checked. The app's sign-in tokens hold it; a browser session is asked
        no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The booking was deleted
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:bookings` ("Required scope: destructive:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
            - `forbidden` — the caller may read the booking but may not delete it ("Not authorized").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no booking with this id that the caller may
            read ("Booking not found"), or the Bookings app is not configured on this
            install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `held_by_meeting` — a Calendar meeting holds this room for this date; it is changed or released from the meeting, and the message says by whom.
            - `inside_cancellation_window` — the booking starts within the tenant's cancellation window, or has already started.
            - `validation_failed` — the delete was refused ("Unable to delete booking.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
  "/bookings/bookings/{id}/cancel":
    parameters:
    - name: id
      in: path
      required: true
      description: The booking's id.
      schema:
        type: integer
    post:
      tags:
      - Bookings
      summary: Cancel a booking
      description: |
        Cancels the booking and KEEPS it, with status `cancelled`, so the
        holder's history, the waitlist and the utilization and cancellation
        counts still see it. The space is freed for the slot. No body.

        Who may: the holder, or someone the tenant's "Who can see everyone's
        bookings?" policy (Bookings → Settings → User Permissions) lets act on
        the holder; anyone else gets `403 forbidden` ("Not authorized"). Only a
        `confirmed` booking can be cancelled (`422 not_cancellable`, e.g.
        "This booking is already cancelled and can't be cancelled."), not once
        it has ended (`422 booking_ended`), and not inside the tenant's
        cancellation window (default 2 hours before the start): `422
        inside_cancellation_window` ("Bookings can only be cancelled N hours in
        advance."). A business admin or Bookings app admin may cancel inside
        the window — unless someone is checked in to the booking, which is
        released by checking them out instead (`422 checked_in`).

        The holder is sent the cancellation, which withdraws the calendar
        invite.

        **Required Scopes:** `destructive:bookings` — cancelling is a
        delete-level act, so `write:bookings` is not enough. A token without it
        gets `403 insufficient_permissions` ("Required scope:
        destructive:bookings") before anything else is checked. The app's
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The booking was cancelled
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - booking
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  booking:
                    "$ref": "#/components/schemas/BookingsBookingCreated"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:bookings` ("Required scope: destructive:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
            - `forbidden` — the caller may read the booking but may not cancel it ("Not authorized").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no booking with this id that the caller may
            read ("Booking not found"), or the Bookings app is not configured on this
            install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `not_cancellable` — the booking is not `confirmed` (already cancelled, completed or a no-show).
            - `booking_ended` — the booking has already ended, so there's nothing to cancel.
            - `held_by_meeting` — a Calendar meeting holds this room for this date; it is changed or released from the meeting. A business admin or Bookings app admin may release the one date (the meeting stays, and its host is told); the message says who can act.
            - `checked_in` — someone is checked in to the booking; checking them out releases it.
            - `inside_cancellation_window` — the booking starts within the tenant's cancellation window (an admin is not bound by it).
            - `validation_failed` — the cancellation could not be saved; `details.messages` lists why.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
  "/bookings/bookings/{id}/check_in":
    parameters:
    - name: id
      in: path
      required: true
      description: The booking's id.
      schema:
        type: integer
    post:
      tags:
      - Bookings
      summary: Check in to a booking
      description: |
        Marks the booking checked in now (`checked_in: true`,
        `checked_in_at`). No body.

        Who may: the holder, someone the tenant's "Who can see everyone's
        bookings?" policy lets act on the holder, or the tenant's facilities staff;
        anyone else gets `403 forbidden` ("Not authorized"). Check-in is open
        to a `confirmed`, not yet checked-in booking from 15 minutes before its
        start until its end; otherwise `422 cannot_check_in` ("Cannot check
        in at this time").

        **Required Scopes:** `write:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:bookings")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Checked in
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsActionResult"
              example:
                success: true
                message: Checked in successfully
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:bookings` ("Required scope: write:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
            - `forbidden` — the caller may read the booking but may not check it in ("Not authorized").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no booking with this id that the caller may
            read ("Booking not found"), or the Bookings app is not configured on this
            install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `cannot_check_in` — outside the check-in window, already checked in, or not `confirmed`.
            - `validation_failed` — the check-in could not be saved; `details.messages` lists why.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
  "/bookings/bookings/{id}/check_out":
    parameters:
    - name: id
      in: path
      required: true
      description: The booking's id.
      schema:
        type: integer
    post:
      tags:
      - Bookings
      summary: Check out of a booking
      description: |
        Records the departure now (`checked_out_at`) and completes the booking
        (status `completed`), freeing the space for the rest of the slot. No
        body. A "check out" entry is written to the app's activity trail and
        the holder is asked to rate the space.

        Who may: as for check-in. Check-out is open to a checked-in booking not
        yet checked out, from its start until 30 minutes after its end;
        otherwise `422 cannot_check_out` ("Cannot check out at this time").

        **Required Scopes:** `write:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:bookings")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Checked out
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsActionResult"
              example:
                success: true
                message: Checked out successfully
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:bookings` ("Required scope: write:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
            - `forbidden` — the caller may read the booking but may not check it out ("Not authorized").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no booking with this id that the caller may
            read ("Booking not found"), or the Bookings app is not configured on this
            install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `cannot_check_out` — not checked in, already checked out, before the start, or more than 30 minutes after the end.
            - `validation_failed` — the check-out could not be saved; `details.messages` lists why.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BookingsValidationError"
  "/bookings/spaces":
    get:
      tags:
      - Bookings
      summary: List bookable spaces
      description: |
        The business's active spaces (rooms, desks, parking spots and other
        resources) ordered by name (then id), paginated — the list an
        integrator books from. Spaces of a type the tenant switched off in
        Bookings settings are left out, as the booking pickers leave them out.

        Filters (each optional):
        - `type` — one space type, or several as `type[]=desk&type[]=phone_booth`.
        - `building` — one building name, or several as `building[]=`.
        - `min_capacity` — spaces holding at least this many people; a
          non-numeric value reads as 0.
          A hash-shaped `type` or `building` is ignored.

        The response also carries `X-Total-Count`, `X-Total-Pages`,
        `X-Current-Page` and `X-Per-Page` headers.

        **Required Scopes:** `read:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:bookings")
        before anything else is checked. The app's sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        description: Spaces per page. A numeric value is clamped to 1..100; a blank
          or non-numeric value means 25.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: type
        in: query
        description: Only spaces of this type. Repeat as `type[]=` for several.
        schema:
          type: string
          enum:
          - desk
          - meeting_room
          - parking_spot
          - locker
          - phone_booth
          - av_equipment
          - company_vehicle
          - gym_equipment
      - name: building
        in: query
        description: Only spaces in this building (exact match). Repeat as `building[]=`
          for several.
        schema:
          type: string
      - name: min_capacity
        in: query
        description: Only spaces whose capacity is at least this number.
        schema:
          type: integer
      responses:
        '200':
          description: One page of the business's active spaces
          headers:
            X-Total-Count:
              description: Spaces matching the filters, across all pages.
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - spaces
                - meta
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  spaces:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BookingsSpaceListItem"
                  meta:
                    "$ref": "#/components/schemas/BookingsPaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:bookings` ("Required scope: read:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business ("Bookings is not enabled").
            - `app_not_accessible` — the caller is not an admin or Bookings app admin and the app is not published to them ("Access denied").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — the Bookings app is not configured on this install."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/bookings/spaces/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The space's id.
      schema:
        type: integer
    get:
      tags:
      - Bookings
      summary: Get a space
      description: |
        One space of the business, with its description, amenities and
        equipment. Any space of the business is returned — including an
        inactive one or one of a type the tenant switched off, which the list
        leaves out. An id from another business answers `404 not_found`
        ("Space not found").

        **Required Scopes:** `read:bookings`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:bookings")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The space
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - space
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  space:
                    "$ref": "#/components/schemas/BookingsSpace"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:bookings` ("Required scope: read:bookings").
            - `app_not_enabled` — Bookings is not enabled for the business.
            - `app_not_accessible` — the caller may not open Bookings ("Access denied").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no space with this id in the business ("Space
            not found"), or the Bookings app is not configured on this install.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/office_integration/manifest.xml":
    get:
      tags:
      - Office Integration
      summary: Get the Office add-in manifest
      description: |
        The add-in's XML manifest (an Office `TaskPaneApp` for Word documents
        and Excel workbooks), for a Microsoft 365 admin to deploy the add-in
        from the Integrated Apps panel. Public — no authentication, no scope.

        The manifest carries no tenant data. Its `SourceLocation`
        (`/office-addin/taskpane.html`), icon URLs and `AppDomain` are built
        from the host the request was sent to, so each workspace address
        serves a manifest pointing at itself.
      security: []
      responses:
        '200':
          description: The add-in manifest
          content:
            application/xml:
              schema:
                type: string
              example: |
                <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
                <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xsi:type="TaskPaneApp">
                  <Id>e4b1f04a-1e7b-4f1d-9a4e-a6d0c8e7f001</Id>
                  <Version>1.0.0.0</Version>
                  <ProviderName>MangoApps</ProviderName>
                  <DisplayName DefaultValue="MangoApps for Microsoft 365"/>
                  <Hosts><Host Name="Document"/><Host Name="Workbook"/></Hosts>
                  <DefaultSettings>
                    <SourceLocation DefaultValue="https://acme.workforce.mangoapps.com/office-addin/taskpane.html"/>
                  </DefaultSettings>
                  <Permissions>ReadWriteDocument</Permissions>
                </OfficeApp>
  "/office_integration/apps":
    get:
      tags:
      - Office Integration
      summary: List the apps for the add-in's picker
      description: |
        The record types the add-in may offer this person in this Office host:
        every registered adapter that is enabled for the business (its app
        enabled, and the app's `office_integration_enabled` setting not off —
        Libraries only when a tenant switched it on; the App attachments and
        Safety Hub incident types are never offered), that supports `host`
        when one is sent, and whose `read:<area>` scope the token holds.

        One entry per record type. Its `slug` is the record type's registry
        key — the `{app_slug}` every records path takes (the table under `GET
        /office_integration/records/{app_slug}/search`) — and `app_slug` is
        the marketplace app it belongs to, which several types share
        (`service-desk` has its knowledge base, help requests, change
        requests and their comments). An app with several types names each
        one: `name` is "<App> — <record type>" (e.g. "Service Desk — Help
        requests"); an app with one type is just its name.

        `capabilities.save_revision` says whether the type can save an edited
        document back (`POST …/revisions`); the add-in offers "Save revision"
        only where it is `true`.

        **Required Scopes:** the `read:<area>` scope of at least one Office
        app — `read:asset_pro`, `read:ballots`, `read:contracts`, `read:drive`,
        `read:epms_goals`, `read:field_service_suite`, `read:ideas`,
        `read:job_board`, `read:libraries`, `read:okr`, `read:onboarding`,
        `read:policy_hub`, `read:procurement`, `read:safety_hub`,
        `read:service_desk`, `read:sop_hub`, `read:tasks`, `read:training` or
        `read:wikis`. A token holding none of them gets `403
        insufficient_permissions` ("Required scope: read:<area> of an Office
        app (e.g. read:drive)") instead of an empty picker. Each app is then
        listed only when its own read scope is held. A browser session is
        asked no scope and sees every enabled app.
      security:
      - BearerAuth: []
      parameters:
      - name: host
        in: query
        description: The Office host the pane runs in. Only apps supporting it are
          listed; any other value lists none. Omit it to list every enabled app.
        schema:
          type: string
          enum:
          - word
          - excel
      responses:
        '200':
          description: The apps to offer
          content:
            application/json:
              schema:
                type: object
                required:
                - apps
                properties:
                  apps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/OfficeIntegrationApp"
        '401':
          description: |
            - Not authenticated (no or an invalid bearer token).
            - `authentication_required` — the request resolved no user or business ("Office Add-in requires authenticated user + business").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` — the token holds no Office app''s
            read scope ("Required scope: read:<area> of an Office app (e.g. read:drive)").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/office_integration/records/{app_slug}/search":
    get:
      tags:
      - Office Integration
      summary: Search an app's records
      description: |
        Finds records of one record type that this person may open, for the
        pane's record picker. A blank `q` returns the first `limit` records;
        otherwise the adapter's own search applies (by default a
        case-insensitive substring match on the record's title). No
        pagination — at most `limit` results.

        Registry keys (`{app_slug}`), the record type each opens, and the
        scope area it asks:

        | `{app_slug}` | Record type | Area |
        |---|---|---|
        | `contracts` | Contract documents | `contracts` |
        | `contracts-vendor-certification` | Vendor certifications | `contracts` |
        | `policy-hub` | Policies | `policy_hub` |
        | `sop-hub` | SOPs | `sop_hub` |
        | `job-board` | Offer letters | `job_board` |
        | `job-board-kb` | Job Board knowledge-base files | `job_board` |
        | `job-board-assessment` | Assessment templates | `job_board` |
        | `drive` | Drive files | `drive` |
        | `app-attachment` | Broadcast-comment, Workspace and DM attachments (never enabled here) | `drive` |
        | `procurement` | Purchase requisitions (Excel) | `procurement` |
        | `field-service-suite` | Estimates (Excel) | `field_service_suite` |
        | `libraries` | Library files | `libraries` |
        | `ballots` | Ballot item documents | `ballots` |
        | `onboarding-hub` | Onboarding document library | `onboarding` |
        | `onboarding-hub-task` | Onboarding task files | `onboarding` |
        | `training` | Training lessons | `training` |
        | `training-custom-field` | Training custom-field files | `training` |
        | `ideas` | Ideas | `ideas` |
        | `tasks-task` | Tasks | `tasks` |
        | `tasks-template` | Task templates | `tasks` |
        | `wikis-page` | Wiki pages | `wikis` |
        | `wikis-comment` | Wiki comments | `wikis` |
        | `epms-goal` | Goals | `epms_goals` |
        | `okr-objective` | OKR objectives | `okr` |
        | `okr-checkin` | OKR check-ins | `okr` |
        | `asset-pro` | Assets | `asset_pro` |
        | `safety-hub-kb` | Safety Hub knowledge-base files | `safety_hub` |
        | `safety-hub-contractor` | Contractor prequalifications | `safety_hub` |
        | `safety-hub-incident` | Incidents (never enabled here) | `safety_hub` |
        | `safety-hub-topic` | Toolbox talk topics | `safety_hub` |
        | `service-desk-kb` | Service Desk knowledge-base files | `service_desk` |
        | `service-desk-help` | Help requests | `service_desk` |
        | `service-desk-change` | Change requests | `service_desk` |
        | `service-desk-comment` | Help request comments | `service_desk` |

        **Required Scopes:** `read:<area>` of the record type (the table). A
        token without it gets `403 insufficient_permissions` ("Required scope:
        read:<area>"), after the key and the app's enablement are checked. An
        own_ form does not count. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: app_slug
        in: path
        required: true
        description: The record type's registry key (see the table on the search operation),
          e.g. `contracts` or `service-desk-help`. Lowercase letters, digits and hyphens
          only.
        schema:
          type: string
          pattern: "^[a-z0-9-]+$"
      - name: q
        in: query
        description: Free-text search. Blank returns the first `limit` records.
        schema:
          type: string
      - name: limit
        in: query
        description: Most results to return. A value of 0 or less (or non-numeric)
          means 25; above 100 means 100.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: X-Office-Host
        in: header
        required: false
        description: The Office host the pane runs in (`word` / `excel`). Recorded
          in the audit log only.
        schema:
          type: string
      - name: X-Office-Addin-Version
        in: header
        required: false
        description: The add-in's version. Recorded in the audit log only.
        schema:
          type: string
      responses:
        '200':
          description: The matching records
          content:
            application/json:
              schema:
                type: object
                required:
                - results
                - count
                properties:
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/OfficeIntegrationSearchResult"
                  count:
                    type: integer
                    description: The number of entries in `results`.
        '401':
          description: |
            - Not authenticated (no or an invalid bearer token).
            - `authentication_required` — the request resolved no user or business ("Office Add-in requires authenticated user + business").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            - `app_disabled` — the record type is not enabled for the business ("Office integration is not enabled for '<app_slug>'").
            - `insufficient_permissions` — the token lacks the type's `read:<area>` ("Required scope: read:<area>").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`unknown_app` — no record type is registered under `{app_slug}`
            ("No Office adapter registered for ''<app_slug>''"). For a marketplace
            slug whose types have their own keys (`service-desk`) the message lists
            them: "… Its record types are addressed as: service-desk-kb, service-desk-help,
            …".'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/office_integration/records/{app_slug}/{id}":
    get:
      tags:
      - Office Integration
      summary: Open a record in the task pane
      description: |
        What the pane shows beside the open document: the record's title,
        subtitle, status, fields, the actions this person may take, the
        actions they would expect but cannot take yet (`blocked_actions`, each
        with the reason), AI insights, the record's web page and its current
        `etag` — send that `etag` as `If-Match` when saving a revision —
        and `capabilities.save_revision`, whether the record type can save
        one at all.

        An action the token's scopes do not cover is moved from `actions` to
        `blocked_actions` with the reason "This token doesn't include <scope>.
        Sign out and choose Sign in to do this here, or open it in MangoApps."
        — a blocked action is information only, and posting it is refused.

        **Required Scopes:** `read:<area>` of the record type (see the search
        operation's table). A token without it gets `403
        insufficient_permissions` ("Required scope: read:<area>"). A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: app_slug
        in: path
        required: true
        description: The record type's registry key (see the table on the search operation),
          e.g. `contracts` or `service-desk-help`. Lowercase letters, digits and hyphens
          only.
        schema:
          type: string
          pattern: "^[a-z0-9-]+$"
      - name: id
        in: path
        required: true
        description: The record's id.
        schema:
          type: integer
      - name: X-Office-Host
        in: header
        required: false
        description: The Office host the pane runs in (`word` / `excel`). Recorded
          in the audit log only.
        schema:
          type: string
      - name: X-Office-Addin-Version
        in: header
        required: false
        description: The add-in's version. Recorded in the audit log only.
        schema:
          type: string
      responses:
        '200':
          description: The record's pane payload
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OfficeIntegrationRecord"
        '401':
          description: |
            - Not authenticated (no or an invalid bearer token).
            - `authentication_required` — the request resolved no user or business ("Office Add-in requires authenticated user + business").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            - `app_disabled` — the record type is not enabled for the business.
            - `insufficient_permissions` — the token lacks the type's `read:<area>` ("Required scope: read:<area>").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `unknown_app` — no record type is registered under `{app_slug}`.
            - `not_found` — no such record, or this person may not open it ("Record not found or not visible to user").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/office_integration/records/{app_slug}/{id}/file":
    get:
      tags:
      - Office Integration
      summary: Download a record's document
      description: |
        The record's document (`.docx`, `.xlsx` or the stored file) as an
        attachment, for the pane to open in Word or Excel. The
        `X-Office-File-ETag` response header carries the stored file's
        checksum when there is one.

        When the record's app asks for identity verification before a
        download (Contracts' step-up setting), a token caller is refused `403
        step_up_required` with the record's web page in `details.web_url`,
        where the person can verify; a web session already verified for that
        scope is let through. A document over 50 MB is refused `413
        file_too_large` — open it in the web app instead. A record with no
        document answers `404 not_found`.

        **Required Scopes:** `read:<area>` of the record type (see the search
        operation's table). A token without it gets `403
        insufficient_permissions` ("Required scope: read:<area>"). A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: app_slug
        in: path
        required: true
        description: The record type's registry key (see the table on the search operation),
          e.g. `contracts` or `service-desk-help`. Lowercase letters, digits and hyphens
          only.
        schema:
          type: string
          pattern: "^[a-z0-9-]+$"
      - name: id
        in: path
        required: true
        description: The record's id.
        schema:
          type: integer
      - name: X-Office-Host
        in: header
        required: false
        description: The Office host the pane runs in (`word` / `excel`). Recorded
          in the audit log only.
        schema:
          type: string
      - name: X-Office-Addin-Version
        in: header
        required: false
        description: The add-in's version. Recorded in the audit log only.
        schema:
          type: string
      responses:
        '200':
          description: The document
          headers:
            Content-Disposition:
              description: '`attachment; filename="<the document''s filename>"`'
              schema:
                type: string
            X-Office-File-ETag:
              description: The stored file's checksum, when the record type provides
                one.
              schema:
                type: string
          content:
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema:
                type: string
                format: binary
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema:
                type: string
                format: binary
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          description: |
            - Not authenticated (no or an invalid bearer token).
            - `authentication_required` — the request resolved no user or business ("Office Add-in requires authenticated user + business").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            - `app_disabled` — the record type is not enabled for the business.
            - `insufficient_permissions` — the token lacks the type's `read:<area>` ("Required scope: read:<area>").
            - `step_up_required` — the tenant requires identity verification to open this file; `details` is `{ "requires_step_up": true, "scope": "<step-up scope>", "web_url": "<the record's web page>" }`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `unknown_app` — no record type is registered under `{app_slug}`.
            - `not_found` — no such record, this person may not open it, or it has no document ("Record not found or not visible to user").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '413':
          description: '`file_too_large` — the document is over 50 MB ("Document exceeds
            the 50 MB Office-edit limit. Open it in the web app instead.").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/office_integration/records/{app_slug}/{id}/revisions":
    post:
      tags:
      - Office Integration
      summary: Save a new revision of a record's document
      description: |
        Uploads the edited document as the record's new revision. Where the
        revision lands is the record type's own rule (Contracts' revision
        history, a Drive file version, a policy or SOP's content, an offer
        letter, a requisition's or estimate's line items read from the sheet).

        Conflict check: send the `etag` from `GET
        /office_integration/records/{app_slug}/{id}` as `If-Match`. When the
        record changed since, nothing is saved and the answer is `409
        etag_conflict` with both tags in `details`.

        A record type may REFUSE the save (a non-draft record, no rows read
        from the sheet, row errors, no text): that is answered `200` with
        `success: false` and the sentence in `error` — read `success`, not the
        status. A save that fails unexpectedly is `422 save_failed`.

        Only Contracts (`contracts`), Policy Hub, SOP Hub, Job Board offer
        letters (`job-board`), Drive, Procurement and Field Service save a
        revision here — the types whose `capabilities.save_revision` is
        `true`. Every other record type answers `422
        save_revision_unsupported` ("Saving revisions isn't supported for
        <App> — <record type>") before the record is looked up, and the
        refusal is audited; its documents are edited in MangoApps (or in
        Microsoft 365 where the tenant turned that on).

        **Required Scopes:** `write:<area>` of the record type (see the search
        operation's table). A token without it gets `403
        insufficient_permissions` ("Required scope: write:<area>"). A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: app_slug
        in: path
        required: true
        description: The record type's registry key (see the table on the search operation),
          e.g. `contracts` or `service-desk-help`. Lowercase letters, digits and hyphens
          only.
        schema:
          type: string
          pattern: "^[a-z0-9-]+$"
      - name: id
        in: path
        required: true
        description: The record's id.
        schema:
          type: integer
      - name: If-Match
        in: header
        required: false
        description: The record's `etag` when the document was opened; a mismatch
          answers 409 and saves nothing.
        schema:
          type: string
      - name: X-Office-Host
        in: header
        required: false
        description: The Office host the pane runs in (`word` / `excel`). Recorded
          in the audit log only.
        schema:
          type: string
      - name: X-Office-Addin-Version
        in: header
        required: false
        description: The add-in's version. Recorded in the audit log only.
        schema:
          type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The edited document. At most 50 MB.
                comment:
                  type: string
                  description: Optional note recorded with the revision.
                filename:
                  type: string
                  description: Used only when the file part carries no filename; defaults
                    to `document.docx`.
      responses:
        '200':
          description: |
            The record type's answer. `success` is `true` unless the record
            type refused the save, in which case it is `false` and `error`
            says why.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OfficeIntegrationRevisionResult"
        '400':
          description: |
            - `missing_file` — no multipart `file` part ("Multipart file part is required").
            - `missing_size` — the upload's size could not be determined.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: |
            - Not authenticated (no or an invalid bearer token).
            - `authentication_required` — the request resolved no user or business ("Office Add-in requires authenticated user + business").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            - `app_disabled` — the record type is not enabled for the business.
            - `insufficient_permissions` — the token lacks the type's `write:<area>` ("Required scope: write:<area>").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `unknown_app` — no record type is registered under `{app_slug}`.
            - `not_found` — no such record, or this person may not open it ("Record not found or not visible to user").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: '`etag_conflict` — the record changed since it was opened;
            `details` is `{ "current_etag", "your_etag" }`. Nothing was saved.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '413':
          description: '`file_too_large` — the upload is over 50 MB ("Maximum revision
            size is 50 MB").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `save_revision_unsupported` — the record type cannot save a revision ("Saving revisions isn't supported for <App> — <record type>").
            - `save_failed` — the save raised an error ("Saving the revision failed. Please try again or open the document in MangoApps."); the detail is in the audit log.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/office_integration/records/{app_slug}/{id}/actions/{name}":
    post:
      tags:
      - Office Integration
      summary: Take an action on a record
      description: |
        Takes one of the record's actions — only a `name` listed in the
        record's `actions` (from `GET /office_integration/records/{app_slug}/{id}`)
        is accepted; anything else, including a `blocked_actions` entry, is
        `403 action_not_allowed`. The actions offered today:

        - `contracts`: `open_web`, `submit_for_approval`, `approve`, `reject`, `send_for_signature`
        - `procurement`: `open_web`, `submit`, `approve`, `reject`
        - `policy-hub`, `sop-hub`: `open_web`, `publish`, `unpublish`
        - `job-board` (offer letters): `open_web`, `submit_for_approval`, `send_to_candidate`
        - `field-service-suite`: `open_web`, `send_to_customer`
        - `drive`: `open_web`
        - every other record type: none

        The JSON body is handed to the record type's action (routing keys and
        `user_id`, `business_id`, `approver_id`, `token`, `access_token`,
        `api_key` and `authenticity_token` are stripped). `reject` takes a
        required `reason` (Contracts also reads `comments`): a blank one is
        refused `422`.

        The answer is the action's result: `200` with `success: true`, or
        `422` when the action refused — the api/v1 error envelope (its `error`
        sentence as `error.message`) with `success: false` and any other keys
        the action returned. An action that raises is `500 action_failed`.

        **Required Scopes**, by action:
        - `open_web` (changes nothing) — `read:<area>` of the record type.
        - `reject` on `contracts` — `destructive:contracts`; on `procurement`
          — `destructive:procurement`. The delete scope alone is asked (not
          the write scope as well), and only after the record is found and
          the action is confirmed to be one it offers.
        - every other action — `write:<area>` of the record type.

        A token without the scope gets `403 insufficient_permissions`
        ("Required scope: <scope>"). See the search operation for each type's
        area. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: app_slug
        in: path
        required: true
        description: The record type's registry key (see the table on the search operation),
          e.g. `contracts` or `service-desk-help`. Lowercase letters, digits and hyphens
          only.
        schema:
          type: string
          pattern: "^[a-z0-9-]+$"
      - name: id
        in: path
        required: true
        description: The record's id.
        schema:
          type: integer
      - name: name
        in: path
        required: true
        description: The action's name, from the record's `actions`. Lowercase letters,
          digits, `_` and `-`.
        schema:
          type: string
          pattern: "^[a-z0-9_-]+$"
        example: approve
      - name: X-Office-Host
        in: header
        required: false
        description: The Office host the pane runs in (`word` / `excel`). Recorded
          in the audit log only.
        schema:
          type: string
      - name: X-Office-Addin-Version
        in: header
        required: false
        description: The add-in's version. Recorded in the audit log only.
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                reason:
                  type: string
                  description: 'Required by `reject`: why it is rejected. The requester
                    or uploader sees it. Capped by the action''s `reason_max`.'
            example:
              reason: Duplicate of REQ-0042
      responses:
        '200':
          description: The action was taken
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OfficeIntegrationActionResult"
              example:
                success: true
                message: Rejected
        '401':
          description: |
            - Not authenticated (no or an invalid bearer token).
            - `authentication_required` — the request resolved no user or business ("Office Add-in requires authenticated user + business").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            - `app_disabled` — the record type is not enabled for the business.
            - `insufficient_permissions` — the token lacks the scope the action asks ("Required scope: <scope>").
            - `action_not_allowed` — the action is not one the record offers this person right now ("Action '<name>' is not available for this record").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `unknown_app` — no record type is registered under `{app_slug}`.
            - `not_found` — no such record, or this person may not open it ("Record not found or not visible to user").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: 'The action refused (e.g. "A rejection reason is required —
            the requester is notified with it."). The api/v1 error envelope — `error.code`
            is the action''s own `code` when it returned one, else `validation_failed`
            — beside `success: false` and the action''s other keys.

            '
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    success:
                      type: boolean
                      enum:
                      - false
                  additionalProperties: true
        '500':
          description: '`action_failed` — the action raised ("Action failed unexpectedly.
            The error has been logged.").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/smart_defaults":
    get:
      tags:
      - Intelligence
      summary: Get the caller's AI-assistant smart defaults
      description: |
        The caller's learned AI-assistant defaults for this business
        (SmartDefaultsEngineService#get_smart_defaults): preferred assistant,
        communication style, complexity, session length, plus keys added for
        the time of day, the `category`, the `assistant_type` and the caller's
        recent workload, and a confidence score with its factors. When the
        caller has no row in the `smart_defaults_calculation` view the base
        values are the fallback set (`source: fallback_defaults`).

        `assistant_type` adds its own keys: `task_assistant`,
        `form_assistant` (also `field_assistance_level`),
        `database_assistant` (also `query_complexity`) and `admin_assistant`
        (also `safety_level`) set `focus_areas`, `suggested_prompts` and
        `interaction_style`; any other value is accepted and adds none.

        **Rate limit:** this endpoint and `contextual_suggestions` share one
        counter per user of 100 calls; the counter expires an hour after the
        last call it counted. Over the limit the call answers
        `429 rate_limited` with `retry_after: 3600`.

        **Required Scopes:** `read:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:chat") before
        anything else is checked. Its own_ form `read:own_chat`, `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/IntelligenceAssistantType"
      - name: category
        in: query
        description: A category to tailor to. When the caller has interaction patterns
          in it with a success rate of 70 or more, `category_complexity`, `category_duration`,
          `category_style` and `category_confidence` are added.
        schema:
          type: string
      - name: context
        in: query
        description: Extra context as a JSON-encoded object string, or as a nested
          query hash (`context[a]=b`). A string that is not a valid JSON object is
          treated as `{}`.
        schema:
          type: string
          example: '{"source":"dashboard"}'
      responses:
        '200':
          description: The caller's smart defaults
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - defaults
                - generated_at
                - expires_at
                - metadata
                properties:
                  success:
                    type: boolean
                    example: true
                  defaults:
                    "$ref": "#/components/schemas/IntelligenceSmartDefaults"
                  generated_at:
                    type: string
                    format: date-time
                  expires_at:
                    type: string
                    format: date-time
                    description: One hour after `generated_at`.
                  metadata:
                    type: object
                    required:
                    - user_id
                    - business_id
                    - assistant_type
                    - category
                    properties:
                      user_id:
                        type: integer
                      business_id:
                        type: integer
                      assistant_type:
                        type: string
                      category:
                        type: string
                        nullable: true
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:chat`
            ("Required scope: read:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/IntelligenceRateLimited"
        '500':
          description: '`internal_server_error` "Failed to generate smart defaults",
            with `fallback_defaults` — the built-in defaults for the requested `assistant_type`
            — beside it.'
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    fallback_defaults:
                      type: object
                      properties:
                        ai_assistant:
                          type: string
                        communication_style:
                          type: string
                          example: moderate
                        complexity_preference:
                          type: string
                          example: moderate
                        session_duration:
                          type: string
                          example: medium
                        preferred_categories:
                          type: array
                          items: {}
                        peak_usage_hours:
                          type: array
                          items: {}
                        confidence_score:
                          type: number
                          example: 0.1
                        source:
                          type: string
                          example: fallback_defaults
  "/intelligence/contextual_suggestions":
    get:
      tags:
      - Intelligence
      summary: Get contextual suggestions for an ongoing AI conversation
      description: |
        What the person might send next in an AI-assistant conversation
        (ContextAwareResponseService#get_contextual_suggestions), ranked by
        `combined_score` and cut to `max_suggestions`:

        - the approaches and response templates recorded on the caller's OWN
          successful interaction patterns (success rate 70+, confidence
          60+) — never another person's;
        - follow-ups for the intent of the latest turn (the `input`, after
          the `conversation_state` messages), one toward `target_outcome`
          when it is given, and one when the person signals urgency.

        With no conversation and no learned patterns the list still holds the
        general follow-ups.

        **Rate limit:** shares the 100-call per-user counter with
        `smart_defaults` (see there); over it, `429 rate_limited`.

        **Required Scopes:** `read:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:chat") before
        anything else is checked. Its own_ form `read:own_chat`, `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: input
        in: query
        description: The text the person is typing or has just sent.
        schema:
          type: string
      - name: conversation_state
        in: query
        description: The conversation so far as a JSON-encoded object string, `{"messages":[{"role":"user","content":"…"}]}`
          — its last 20 messages are read. A string that is not a valid JSON object,
          or one without a `messages` array, reads as no conversation.
        schema:
          type: string
      - name: target_outcome
        in: query
        description: What the conversation is trying to reach.
        schema:
          type: string
      - name: max_suggestions
        in: query
        description: How many suggestions to return, at most 20. Missing, zero, negative
          or not a whole number reads as 5.
        schema:
          type: integer
          default: 5
          minimum: 1
          maximum: 20
      responses:
        '200':
          description: Ranked suggestions
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - suggestions
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  suggestions:
                    type: object
                    properties:
                      suggestions:
                        type: array
                        items:
                          "$ref": "#/components/schemas/IntelligenceContextualSuggestion"
                      context_confidence:
                        type: number
                      similar_patterns_found:
                        type: integer
                      generated_at:
                        type: string
                        format: date-time
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:chat`
            ("Required scope: read:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/IntelligenceRateLimited"
        '500':
          description: '`internal_server_error` "Failed to generate suggestions",
            with an empty `suggestions` array beside it, when the suggestion service
            raises.'
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    suggestions:
                      type: array
                      maxItems: 0
                      items: {}
  "/intelligence/user_insights":
    get:
      tags:
      - Intelligence
      summary: Get the caller's AI-assistant intelligence insights
      description: |
        Everything learned about the caller's AI-assistant use in this
        business (IntelligenceTrackingService#get_user_intelligence_insights):
        their smart defaults for `assistant_type`, a behaviour analysis,
        workflow recommendations from their successful patterns, optimisation
        opportunities, learning progress and confidence metrics — plus the
        headline numbers of their intelligence profile.

        **Required Scopes:** `read:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:chat") before
        anything else is checked. Its own_ form `read:own_chat`, `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/IntelligenceAssistantType"
      responses:
        '200':
          description: The caller's insights
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - insights
                - user_profile
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  insights:
                    type: object
                    properties:
                      smart_defaults:
                        "$ref": "#/components/schemas/IntelligenceSmartDefaults"
                      behavior_analysis:
                        type: object
                        description: UserIntelligenceProfile#get_personalization_insights.
                        properties:
                          intelligence_level:
                            type: string
                          pattern_confidence:
                            type: string
                          interaction_style:
                            type: object
                            additionalProperties: true
                          learning_progress:
                            type: object
                            additionalProperties: true
                          optimization_opportunities:
                            type: array
                            items:
                              type: object
                              additionalProperties: true
                          behavioral_trends:
                            type: object
                            additionalProperties: true
                          prediction_accuracy:
                            type: number
                      pattern_recommendations:
                        type: array
                        description: Up to five of the caller's patterns with a success
                          rate of 70+ and confidence of 60+, strongest first.
                        items:
                          type: object
                          properties:
                            workflow_type:
                              type: string
                            success_rate:
                              type: string
                              description: Decimal, as a string.
                            confidence:
                              type: string
                              description: Decimal, as a string.
                            workflow_steps:
                              type: array
                              items: {}
                            usage_frequency:
                              type: integer
                            recommendation_strength:
                              type: number
                      optimization_opportunities:
                        type: array
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                            priority:
                              type: string
                            description:
                              type: string
                      learning_progress:
                        type: object
                        additionalProperties: true
                        description: Empty when it cannot be computed.
                      confidence_metrics:
                        type: object
                        properties:
                          pattern_confidence:
                            type: string
                            description: Decimal, as a string.
                          data_sufficiency:
                            type: number
                          prediction_accuracy:
                            type: number
                          feedback_consistency:
                            type: number
                  user_profile:
                    type: object
                    required:
                    - intelligence_score
                    - pattern_confidence
                    - total_interactions
                    - last_interaction
                    - satisfaction_score
                    properties:
                      intelligence_score:
                        type: string
                        description: Decimal, as a string; a new profile starts at
                          "25.0".
                        example: '25.0'
                      pattern_confidence:
                        type: string
                        description: Decimal, as a string.
                        example: '0.0'
                      total_interactions:
                        type: integer
                      last_interaction:
                        type: string
                        format: date-time
                        nullable: true
                      satisfaction_score:
                        type: string
                        nullable: true
                        description: Decimal, as a string.
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:chat`
            ("Required scope: read:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to generate user insights".'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/business_analytics":
    get:
      tags:
      - Intelligence
      summary: Get business-wide AI-assistant analytics (admins)
      description: |
        Business-wide AI-assistant analytics for an admin: how many people
        prefer each assistant, communication style, complexity and session
        length (from the `smart_defaults_calculation` view), plus weekly
        pattern trends over 12 weeks, a user segmentation by intelligence
        score and optimisation recommendations.

        Only an admin or super admin of the business is answered; anyone else
        gets `403 forbidden` ("Unauthorized").

        **Required Scopes:** `read:analytics`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:analytics")
        before anything else is checked. Only the sign-in doors mint it, so a
        token made by hand never carries an admin's width; `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Business analytics
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - analytics
                - business_insights
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  analytics:
                    type: object
                    description: 'One aggregate row: `active_users`, `avg_confidence`,
                      `task_assistant_users`, `form_assistant_users`, `database_assistant_users`,
                      `detailed_style_users`, `moderate_style_users`, `concise_style_users`,
                      `high_complexity_users`, `moderate_complexity_users`, `low_complexity_users`,
                      `long_session_users`, `medium_session_users`, `short_session_users`,
                      `common_peak_hours_count`, `avg_hours_since_calculation`.'
                    additionalProperties: true
                  business_insights:
                    type: object
                    description: The same aggregate row plus `pattern_trends` (rows
                      of `week`, `pattern_type`, `pattern_count`, `avg_success_rate`,
                      `avg_confidence`), `user_segmentation` (rows of `user_segment`,
                      `user_count`, `avg_intelligence_score`, `avg_pattern_confidence`,
                      `avg_interactions`, highest score first) and `optimization_recommendations`
                      (objects of `type`, `priority`, `description`, `action`).
                    additionalProperties: true
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:analytics` ("Required scope: read:analytics").
            - `forbidden` — "Unauthorized": the caller is not an admin or super admin of the business.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to generate business analytics",
            when an analytics query raises.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/feedback":
    post:
      tags:
      - Intelligence
      summary: Send feedback on an AI-assistant interaction
      description: |
        Records the caller's feedback on an AI-assistant answer so their
        defaults can learn from it, and returns improvement suggestions read
        from the feedback flags.

        The feedback adapts the caller's profile: `communication_feedback`
        (`too_detailed` → concise, `too_concise` → detailed) and
        `complexity_feedback` (`too_complex` / `too_simple`) move their
        defaults, and `just_right` answers raise the confidence in them.

        A `session_id` the caller has not used before starts a tracking
        session for them under that id (one row per id — a later call with
        the same id finds it), and the feedback is also learned against the
        defaults that session started with. Only the caller's own sessions
        are ever found by id; an id another person's session already holds
        starts the caller a session under a fresh id instead.

        **Required Scopes:** `write:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:chat") before
        anything else is checked. Its own_ form `write:own_chat` and `admin`
        also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                feedback_type:
                  type: string
                  description: What kind of feedback this is. Not validated; the code
                    names `satisfaction`, `preference_change` and `error_report`.
                  example: satisfaction
                feedback_data:
                  type: object
                  description: The feedback itself, as a JSON object (or a JSON-encoded
                    object string). Three flags produce improvement suggestions when
                    they are JSON `true`.
                  properties:
                    communication_feedback:
                      type: string
                      enum:
                      - too_detailed
                      - too_concise
                      - just_right
                    complexity_feedback:
                      type: string
                      enum:
                      - too_complex
                      - too_simple
                      - just_right
                    communication_too_complex:
                      type: boolean
                    needed_more_examples:
                      type: boolean
                    too_slow:
                      type: boolean
                  additionalProperties: true
                session_id:
                  type: string
                  description: The AI-assistant session the feedback is about.
                assistant_type:
                  type: string
                  default: task_assistant
      responses:
        '200':
          description: Feedback recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - message
                - improvement_suggestions
                - recorded_at
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Feedback recorded successfully
                  improvement_suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        area:
                          type: string
                          enum:
                          - communication_simplification
                          - example_provision
                          - response_speed
                        action:
                          type: string
                        priority:
                          type: string
                          enum:
                          - high
                          - medium
                  recorded_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `write:chat`
            ("Required scope: write:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to record feedback", when
            saving the feedback raises.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/prompt_suggestions":
    get:
      tags:
      - Intelligence
      summary: Get prompt suggestions for the caller
      description: |
        Active, high-intelligence prompt templates matched to the caller's
        preferred complexity, best `intelligence_score` first — the system's
        templates and this business's own, never another business's. A lookup error
        inside the service answers an empty list, not a 500.

        **Required Scopes:** `read:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:chat") before
        anything else is checked. Its own_ form `read:own_chat`, `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        description: Only templates in this prompt category.
        schema:
          type: string
      - name: user_intent
        in: query
        description: Only templates whose name or content contains this text (case-insensitive).
        schema:
          type: string
      - name: max_suggestions
        in: query
        description: How many templates to return, at most 20. Missing, zero, negative
          or not a whole number reads as 5.
        schema:
          type: integer
          default: 5
          minimum: 1
          maximum: 20
      responses:
        '200':
          description: Prompt suggestions
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - suggestions
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  suggestions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/IntelligencePromptSuggestion"
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:chat`
            ("Required scope: read:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to generate prompt suggestions",
            with an empty `suggestions` array beside it.'
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    suggestions:
                      type: array
                      maxItems: 0
                      items: {}
  "/intelligence/workflow_defaults":
    get:
      tags:
      - Intelligence
      summary: Get recommended defaults for a workflow
      description: |
        Recommended steps, duration, complexity and assistant for a workflow
        type, built from the business's successful workflow patterns of that
        type (success rate 70+, rolling confidence 60+, top ten). With no such
        pattern the fallback set is returned: no steps, 300 seconds,
        `moderate`, 0.5 success probability, `task_assistant`, confidence
        0.1.

        **Required Scopes:** `read:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:chat") before
        anything else is checked. Its own_ form `read:own_chat`, `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: workflow_type
        in: query
        description: The interaction pattern type to recommend for (e.g. `task_creation`).
        schema:
          type: string
      - name: workflow_context
        in: query
        description: Accepted but not used.
        schema:
          type: string
      responses:
        '200':
          description: Workflow defaults
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - workflow_defaults
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  workflow_defaults:
                    type: object
                    required:
                    - workflow_type
                    - recommended_steps
                    - estimated_duration
                    - complexity_level
                    - success_probability
                    - suggested_ai_assistant
                    - context_requirements
                    - confidence_score
                    properties:
                      workflow_type:
                        type: string
                        nullable: true
                        description: The `workflow_type` asked for.
                      recommended_steps:
                        type: array
                        items: {}
                      estimated_duration:
                        type: number
                        description: Seconds; 300 when unknown.
                      complexity_level:
                        type: string
                        enum:
                        - simple
                        - moderate
                        - complex
                      success_probability:
                        type: number
                        description: 0..1; 0.5 when unknown.
                      suggested_ai_assistant:
                        type: string
                        example: task_assistant
                      context_requirements:
                        type: array
                        description: Up to five context keys the successful patterns
                          most often carried.
                        items:
                          type: string
                      confidence_score:
                        type: number
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:chat`
            ("Required scope: read:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to generate workflow defaults".'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/pattern_analytics":
    get:
      tags:
      - Intelligence
      summary: Get analytics of the caller's interaction patterns
      description: |
        Counts, success rates and confidence of the caller's own interaction
        patterns in this business, by pattern type, with a trend series and
        their ten most successful patterns.

        `time_range` limits the patterns to the last 7, 30 or 90 days by
        creation; any other value applies no time limit. `trend_data`: for
        `7_days` and `30_days`, one key per day (`YYYY-MM-DD`) with that
        day's count (0 for a day with none); for `90_days`, the count per
        week keyed by the week's start timestamp; otherwise the count per day
        keyed by date.

        **Required Scopes:** `read:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:chat") before
        anything else is checked. Its own_ form `read:own_chat`, `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: time_range
        in: query
        description: How far back to look. Any other value means no time limit.
        schema:
          type: string
          enum:
          - 7_days
          - 30_days
          - 90_days
          default: 30_days
      - name: pattern_types[]
        in: query
        description: Only these pattern types, sent as an array (`pattern_types[]=a&pattern_types[]=b`)
          or as one plain `pattern_types=a` value.
        style: form
        explode: true
        schema:
          type: array
          items:
            type: string
      responses:
        '200':
          description: Pattern analytics
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - analytics
                - time_range
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  analytics:
                    type: object
                    required:
                    - total_patterns
                    - pattern_distribution
                    - success_rates
                    - confidence_scores
                    - trend_data
                    - top_patterns
                    properties:
                      total_patterns:
                        type: integer
                      pattern_distribution:
                        type: object
                        description: Pattern type → count.
                        additionalProperties:
                          type: integer
                      success_rates:
                        type: object
                        description: Pattern type → average success rate (decimal,
                          as a string).
                        additionalProperties:
                          type: string
                      confidence_scores:
                        type: object
                        description: Pattern type → average confidence (decimal, as
                          a string).
                        additionalProperties:
                          type: string
                      trend_data:
                        type: object
                        description: Date or week → count (see the operation description).
                        additionalProperties:
                          type: integer
                      top_patterns:
                        type: array
                        description: Up to ten patterns, highest success rate first.
                        items:
                          type: object
                          properties:
                            pattern_type:
                              type: string
                            success_rate:
                              type: string
                              description: Decimal, as a string.
                            confidence_score:
                              type: string
                              description: Decimal, as a string.
                            occurrence_count:
                              type: integer
                            created_at:
                              type: string
                              format: date-time
                  time_range:
                    type: string
                    description: The `time_range` asked for (default `30_days`).
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '- `insufficient_permissions` — the token lacks `read:chat`
            ("Required scope: read:chat").

            '
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to generate pattern analytics".'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/performance_metrics":
    get:
      tags:
      - Intelligence
      summary: Get AI-assistant intelligence performance metrics (admins)
      description: |
        Health and performance of the AI-assistant intelligence system for an
        admin: the answering server process's own operation timings over the
        last hour (in-memory, so they differ between servers and reset on
        restart), this business's profile, pattern and session counts, a
        system health check, and a 24-hour performance report for the
        business.

        Only an admin or super admin of the business is answered; anyone else
        gets `403 forbidden` ("Unauthorized").

        **Required Scopes:** `read:analytics`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:analytics")
        before anything else is checked. Only the sign-in doors mint it, so a
        token made by hand never carries an admin's width; `admin` and
        `read:all` also pass. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Performance metrics
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - metrics
                - generated_at
                properties:
                  success:
                    type: boolean
                    example: true
                  metrics:
                    type: object
                    required:
                    - system_performance
                    - business_performance
                    - system_health
                    - performance_report
                    properties:
                      system_performance:
                        type: object
                        description: This server process's last hour.
                        properties:
                          total_operations:
                            type: integer
                          success_rate:
                            type: number
                          avg_response_time:
                            type: number
                          p95_response_time:
                            type: number
                          error_rate:
                            type: number
                          cache_performance:
                            type: object
                            properties:
                              hit_rate:
                                type: number
                              total_requests:
                                type: integer
                          slow_operations:
                            type: object
                            additionalProperties: true
                          error_breakdown:
                            type: object
                            additionalProperties: true
                          system_health:
                            type: string
                      business_performance:
                        "$ref": "#/components/schemas/IntelligenceBusinessPerformance"
                      system_health:
                        type: object
                        properties:
                          overall_status:
                            type: string
                            example: healthy
                          checks:
                            type: object
                            description: One object each for `database`, `materialized_views`,
                              `background_jobs`, `cache_performance`, `api_performance`
                              and `error_rates`.
                            additionalProperties:
                              type: object
                              additionalProperties: true
                          alerts:
                            type: array
                            items:
                              type: object
                              properties:
                                type:
                                  type: string
                                component:
                                  type: string
                                message:
                                  type: string
                                severity:
                                  type: string
                          last_check:
                            type: string
                            format: date-time
                      performance_report:
                        type: object
                        properties:
                          generated_at:
                            type: string
                            format: date-time
                          time_range:
                            description: The report window (24 hours), as Rails serializes
                              a duration.
                          report_type:
                            type: string
                            enum:
                            - business
                          business_metrics:
                            "$ref": "#/components/schemas/IntelligenceBusinessPerformance"
                          recommendations:
                            type: array
                            items:
                              type: object
                              additionalProperties: true
                          action_items:
                            type: array
                            items:
                              type: object
                              additionalProperties: true
                  generated_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:analytics` ("Required scope: read:analytics").
            - `forbidden` — "Unauthorized": the caller is not an admin or super admin of the business.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to generate performance metrics".'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/intelligence/refresh_patterns":
    post:
      tags:
      - Intelligence
      summary: Schedule a re-analysis of a person's interaction patterns
      description: |
        Queues a pattern analysis (UserPatternAnalysisJob) for the caller, or
        — for an admin or super admin of the business — for the person named
        by `user_id`. Creates that person's intelligence profile for this
        business if they have none.

        `user_id` must be a member of this business (any membership row) or
        the call answers `404 not_found`. A caller who is not an admin or
        super admin may only name themselves; anyone else gets
        `403 forbidden` ("Unauthorized").

        **Required Scopes:** `write:chat`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:chat") before
        anything else is checked. For your own patterns (no `user_id`, or
        your own id) its own_ form `write:own_chat` and `admin` also pass.
        Refreshing ANOTHER person's patterns (a `user_id` that is not the
        caller's) asks the wide `write:chat` itself — `write:own_chat` does
        not stand in for it; only `write:chat` or `admin` pass. A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                  description: Whose patterns to refresh; the caller when omitted.
                    Also read from the query string.
      responses:
        '200':
          description: Analysis queued
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - message
                - user_id
                - scheduled_at
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Pattern analysis scheduled
                  user_id:
                    type: integer
                  scheduled_at:
                    type: string
                    format: date-time
        '400':
          "$ref": "#/components/responses/IntelligenceBusinessContextRequired"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:chat` ("Required scope: write:chat"); for another person's `user_id`, the token holds only the own_ form.
            - `forbidden` — "Unauthorized": a caller who is not an admin or super admin named someone else.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — "The requested resource was not found.": `user_id`
            is not a member of this business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`invalid_parameter` — "user_id must be one person''s id":
            `user_id` was sent as a list.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: '`internal_server_error` "Failed to schedule pattern refresh".'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employees/{id}":
    get:
      tags:
      - Employees
      summary: Get a colleague's directory card
      description: |
        One person's directory card in the caller's business. `{id}` is the
        person's user id and is looked up among everyone with a membership in
        this business — a deactivated membership is still found; anyone else
        is `404 not_found`.

        `email` and `phone` are included only for a browser session or a
        token holding `read:users` (`admin` and `read:all` count); every
        other token gets the card without them.

        Every card field also follows the People directory's field rules
        for the caller (a token's own user): `position`, `manager` and
        `department` are null, and `email` and `phone` are left out, where
        the directory hides that field from them. An admin, or a business
        without People field rules, gets every field.

        **Required Scopes:** `read:people_directory`. A token without it gets
        `403 insufficient_permissions` ("Insufficient permissions. Required
        scope: read:people_directory") before anything else is checked.
        `read:users` implies it, and `admin` and `read:all` also pass. A
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The person's user id.
        schema:
          type: integer
      responses:
        '200':
          description: The directory card
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/EmployeesCard"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:people_directory` ("Insufficient permissions. Required scope: read:people_directory"); `code` is repeated at the top level.
            - `forbidden` — "No business is selected for this session." (browser session only).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/EmployeesScopeError"
        '404':
          description: '`not_found` — "Employee not found": no member of this business
            has that id.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employees/{id}/schedule":
    get:
      tags:
      - Employees
      summary: Get a person's shift schedule
      description: |
        The shifts `{id}` is assigned to in this business whose start falls
        between `start_date` 00:00 and `end_date` 23:59:59 in the caller's
        time zone, earliest first. Every assigned shift is listed, whatever
        its status (cancelled included). Each shift's date and times are
        given in the shift's own location time zone.

        Without dates the window is today through seven days later; without
        `end_date` it is `start_date` plus seven days. The window is not
        capped, and an `end_date` before `start_date` returns no shifts.

        A caller reads their own schedule; another person's needs the caller
        to be a manager, admin or super admin of the business
        (`403 forbidden` otherwise).

        **Required Scopes:** `read:shifts`. A token without it gets
        `403 insufficient_permissions` ("Insufficient permissions. Required
        scope: read:shifts") before anything else is checked. For the
        caller's own id the own_ form `read:own_shifts` also passes; for
        anyone else's only the wide `read:shifts` does. `admin` and
        `read:all` pass either way. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The person's user id.
        schema:
          type: integer
      - name: start_date
        in: query
        description: First day of the window, `YYYY-MM-DD` (any format Ruby's `Date.parse`
          reads is accepted). Defaults to today.
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: Last day of the window, `YYYY-MM-DD`. Defaults to `start_date`
          plus seven days.
        schema:
          type: string
          format: date
      responses:
        '200':
          description: The schedule
          content:
            application/json:
              schema:
                type: object
                required:
                - employee_id
                - employee_name
                - start_date
                - end_date
                - shifts
                properties:
                  employee_id:
                    type: integer
                  employee_name:
                    type: string
                  start_date:
                    type: string
                    format: date
                  end_date:
                    type: string
                    format: date
                  shifts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EmployeesScheduleShift"
        '400':
          description: '`invalid_request` — "Invalid date format. Please use YYYY-MM-DD.":
            `start_date` or `end_date` cannot be parsed as a date.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:shifts` ("Insufficient permissions. Required scope: read:shifts"), or holds only `read:own_shifts` for someone else's id; `code` is repeated at the top level.
            - `forbidden` — "You can read your own schedule; a colleague's is for their manager.": the caller is not a manager, admin or super admin of the business and `{id}` is not their own; `code` is repeated at the top level.
            - `forbidden` — "No business is selected for this session." (browser session only).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/EmployeesScopeError"
        '404':
          description: '`not_found` — "Employee not found": no member of this business
            has that id.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recent_entities":
    get:
      tags:
      - Recent Entities
      summary: List recently updated employees, shifts or locations
      description: |
        Up to `limit` records of one type from the caller's business, most
        recently updated first.

        - `employee` — everyone with a membership in the business
          (deactivated memberships included), by the user record's
          `updated_at`. A row carries the person's `email` only for a browser
          session or a token holding `read:users` (`admin` and `read:all`
          count), and only where the People directory shows email to the
          caller — the rule a colleague's card (GET /employees/{id}) applies;
          everyone else gets the row without it. `position` is null where the
          directory hides Job Function from the caller.
        - `shift` — for a manager, admin or super admin of the business, all
          its shifts; for anyone else, only the shifts they are assigned to.
        - `location` — all the business's locations, active or not.

        `updated_at` is `YYYY-MM-DD HH:MM` in the caller's time zone (no
        offset); a shift's `date` and `time` are in the shift location's
        time zone.

        **Required Scopes:** depends on `entity_type`:

        | `entity_type` | Scope asked |
        |---|---|
        | `employee` | `read:people_directory` (`read:users` implies it) |
        | `shift` | `read:shifts` — the wide scope; `read:own_shifts` does not pass, even though a member is shown only their own shifts |
        | `location` | `read:organization` |

        A token without the type's scope gets `403 insufficient_permissions`
        ("Insufficient permissions. Required scope: …"). `admin` and
        `read:all` pass for every type. A blank `entity_type` is refused with
        400 before any scope is asked, and an unknown type asks no scope and
        is refused with 400. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: entity_type
        in: query
        required: true
        description: Which kind of record to list.
        schema:
          type: string
          enum:
          - employee
          - shift
          - location
      - name: limit
        in: query
        description: How many records to return, capped at 50. A missing, zero, negative
          or non-numeric value reads as the default, 10.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: The recent records of the requested type
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/RecentEntitiesEmployeeList"
                - "$ref": "#/components/schemas/RecentEntitiesShiftList"
                - "$ref": "#/components/schemas/RecentEntitiesLocationList"
                discriminator:
                  propertyName: entity_type
                  mapping:
                    employee: "#/components/schemas/RecentEntitiesEmployeeList"
                    shift: "#/components/schemas/RecentEntitiesShiftList"
                    location: "#/components/schemas/RecentEntitiesLocationList"
        '400':
          description: |
            - `invalid_request` — "Entity type is required": `entity_type` is missing or blank.
            - `invalid_request` — "Unknown entity type: <value>": `entity_type` is not `employee`, `shift` or `location`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks the type's scope ("Insufficient permissions. Required scope: read:people_directory" / "read:shifts" / "read:organization"); `code` is repeated at the top level.
            - `forbidden` — "No business is selected for this session." (browser session only).
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    code:
                      type: string
                      example: insufficient_permissions
  "/notes":
    post:
      tags:
      - Notes
      summary: Add a Performance note to a record
      description: |
        Creates a note authored by the caller on one record of their business.
        The record is named by `notable_type` + `notable_id` (or the aliases
        `entity_type` + `entity_id`; `notable_*` wins when both are sent).

        Who may add a note, per type:

        | `notable_type` | The caller may add a note when |
        |---|---|
        | `PerformanceReview` | they may access the review |
        | `Goal` | it is their own goal, or they may manage its employee |
        | `Apps::Epms::Meeting` | they may open the meeting |
        | `DevelopmentPlan` | it is their own plan, or they may manage its employee |
        | `User` | it is themselves, or they may manage that person |

        A type outside this list, a missing id, a record that is not this
        business's and a record the caller may not see all get the same
        `403` ("You do not have access to that record.") — it never says
        whether the record exists.

        The Performance app's **Notes & Attachments** module must be enabled
        for the business; otherwise `403` ("Notes & Attachments is not
        enabled for this business.").

        Honours `Idempotency-Key`: a retry with the same key returns the
        stored `201` instead of creating a second note.

        **Required Scopes:** the write scope of the record the note hangs on:

        | `notable_type` | Scope asked |
        |---|---|
        | `PerformanceReview` | `write:epms_reviews` |
        | `Goal` | `write:epms_goals` |
        | `Apps::Epms::Meeting` | `write:epms_meetings` |
        | `DevelopmentPlan` | `write:epms_development` |
        | `User` | `write:epms_feedback` |

        A token without it gets `403 insufficient_permissions`
        ("Insufficient permissions. Required scope: write:epms_goals") before
        the module or the record is checked; `admin` also passes. A type
        outside the table asks no scope and is refused by the access check.
        A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/NotesCreateRequest"
          application/x-www-form-urlencoded:
            schema:
              "$ref": "#/components/schemas/NotesCreateRequest"
      responses:
        '201':
          description: The note was created
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - note_id
                - message
                properties:
                  success:
                    type: boolean
                    example: true
                  note_id:
                    type: integer
                  message:
                    type: string
                    example: Note created successfully
        '401':
          description: |
            - `unauthorized` — "Unauthorized access": no valid token or session.
            - `unauthorized` — "Current user not available": the request authenticated without a person.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks the record type's write scope ("Insufficient permissions. Required scope: write:epms_reviews" and so on); `code` is repeated at the top level. Error envelope.
            - `forbidden` — "No business is selected for this session." (browser session only). Error envelope.
            - `{ success: false, message: "Notes & Attachments is not enabled for this business." }` — the Performance Notes & Attachments module is off.
            - `{ success: false, message: "You do not have access to that record." }` — the type is not one of the five, the id is missing, the record is not this business's, or the caller may not see it.
          content:
            application/json:
              schema:
                oneOf:
                - allOf:
                  - "$ref": "#/components/schemas/Error"
                  - type: object
                    properties:
                      code:
                        type: string
                        example: insufficient_permissions
                - "$ref": "#/components/schemas/NotesRefusal"
        '422':
          description: |
            - `{ success: false, message: "Invalid note type." }` — `note_type` is not one of the listed values.
            - `{ success: false, message: "Invalid visibility." }` — `visibility` is not one of the listed values.
            - `{ success: false, message: "Failed to create note", errors: [...] }` — the note did not save, e.g. "Content can't be blank" or "Content is too long (maximum is 5000 characters)".
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/NotesRefusal"
                - type: object
                  properties:
                    errors:
                      type: array
                      description: The model's full error messages; only on a failed
                        save.
                      items:
                        type: string
  "/integrations/social":
    get:
      tags:
      - Social Publishing
      summary: List connected social providers
      description: |
        Returns the list of social-publishing providers the calling business has
        connected and what each one can do. External CMS integrations call this
        first to feature-detect available providers before constructing a publish.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Connected providers
          content:
            application/json:
              schema:
                type: object
                properties:
                  providers:
                    type: array
                    items:
                      type: object
                      required:
                      - provider
                      - status
                      - capabilities
                      properties:
                        provider:
                          type: string
                          example: linkedin
                          description: Provider slug under the 'social' category.
                        status:
                          type: string
                          enum:
                          - enabled
                          - disabled
                        health:
                          type: string
                          enum:
                          - healthy
                          - warning
                          - error
                          - unknown
                        connected_at:
                          type: string
                          format: date-time
                          nullable: true
                        capabilities:
                          type: array
                          description: |
                            Flat list of capability strings. LinkedIn capabilities:
                            `publish_organization` (org_urn configured),
                            `publish_member` (linkedin_member_id configured).
                          items:
                            type: string
                            example: publish_organization
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Token missing `read:social_publishing` scope.
  "/integrations/social/linkedin/posts":
    post:
      tags:
      - Social Publishing
      summary: Publish a post to LinkedIn
      description: |
        Publishes a post to the connected LinkedIn organization page (default)
        or to the connected member's personal feed. The business must have a
        LinkedIn `SystemIntegration` row in `enabled` status.

        **Idempotency:** pass an `Idempotency-Key` header to dedupe retries —
        the response is cached for 24h via Redis, and the
        `social_publish_logs` table has a unique constraint on
        `(business_id, idempotency_key)` as a fallback.
      security:
      - BearerAuth: []
      parameters:
      - in: header
        name: Idempotency-Key
        schema:
          type: string
        description: Optional UUID for retry-safe dedup.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              properties:
                text:
                  type: string
                  description: Post body. Truncated to 3000 characters.
                  example: We're hiring for our retail-ops team — come build with
                    us.
                author:
                  type: string
                  enum:
                  - organization
                  - member
                  default: organization
                link_url:
                  type: string
                  format: uri
                  nullable: true
                  description: Optional article URL — attaches a link card.
                visibility:
                  type: string
                  enum:
                  - PUBLIC
                  - CONNECTIONS
                  default: PUBLIC
                  description: |
                    `CONNECTIONS` only applies to `author: member`. LinkedIn
                    rejects it for organization posts.
      responses:
        '201':
          description: Post published or scheduled.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SocialPostEnvelope"
        '412':
          description: |
            LinkedIn is not connected for this business. Admin must complete
            OAuth at `/admin/integrations/browse_integrations?category=social`
            before publishing.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorEnvelope"
        '422':
          description: Invalid payload (missing text, bad author value).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorEnvelope"
        '502':
          description: |
            Upstream LinkedIn error. `error.code` is one of:
            `rate_limited`, `provider_error`, `auth_expired`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorEnvelope"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Token missing `write:social_publishing` scope.
        '409':
          description: Idempotency-Key reused with a different request body.
  "/integrations/social/linkedin/posts/{urn}":
    parameters:
    - in: path
      name: urn
      required: true
      schema:
        type: string
      description: |
        LinkedIn URN of a previously-published post (e.g.
        `urn:li:share:1234567890`). URL-encoded colons are accepted.
    get:
      tags:
      - Social Publishing
      summary: Fetch publish status by URN
      description: |
        Reads the `social_publish_logs` row for a previously-published URN.
        Does NOT round-trip to LinkedIn — status reflects what we recorded
        at publish time.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Publish log entry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SocialPostEnvelope"
        '404':
          description: No log row matches that URN for the calling business.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Token missing `read:social_publishing` scope.
    delete:
      tags:
      - Social Publishing
      summary: Delete a published LinkedIn post
      description: Calls LinkedIn's delete endpoint and marks the log row deleted.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Deleted.
        '412':
          description: LinkedIn is not connected.
        '502':
          description: Upstream LinkedIn error.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Token missing `write:social_publishing` scope.
  "/webhooks":
    get:
      tags:
      - Webhooks
      summary: List active webhooks
      description: |
        The business's ACTIVE webhooks (a webhook switched off, by hand or
        after repeated failures, is left out — read it with GET
        /webhooks/{id}), paginated, in no guaranteed order. No webhook in the
        list carries its signing secret.

        **Required Scopes:** `read:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:webhooks")
        before anything else is checked. The caller must also be an admin or
        super admin of the business, whatever the token holds. A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Webhooks per page. Clamped to 1..100; a blank or non-numeric
          value reads as 0 and is clamped to 1.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of the business's active webhooks
          content:
            application/json:
              schema:
                type: object
                required:
                - webhooks
                - meta
                properties:
                  webhooks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WebhooksWebhook"
                  meta:
                    "$ref": "#/components/schemas/WebhooksListMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:webhooks` ("Required scope: read:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Webhooks
      summary: Register a webhook
      description: |
        Registers a webhook for the caller's business. The response is the only
        one that carries the webhook's signing secret (`secret_key`): one is
        generated (40 hex characters) unless the body sets its own. Keep it —
        no later response returns it.

        `url` must be a full `http://` or `https://` address with a host, no
        `user:password@` and no `#fragment`, and unique within the business.
        `event_types` must be non-empty and name events from GET
        /webhooks/event_types (its `event_types`, or — for a caller who may
        manage webhooks — a `plugin_event_types[].event_type`), or `"*"` for
        every event in `event_types` (never a plugin event, and never the
        people lifecycle `person.*` events, which a webhook receives only by
        naming them). Every name the platform knows is accepted, including
        events whose platform switch is off.

        A webhook has no name field: `description` is its label. A `name` key
        is accepted as an alias for `description` when `description` is not
        sent, and ignored when it is.

        **Required Scopes:** `write:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:webhooks")
        before anything else is checked. The caller must also be an admin or
        super admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - webhook
              properties:
                webhook:
                  allOf:
                  - "$ref": "#/components/schemas/WebhooksWebhookInput"
                  - type: object
                    required:
                    - url
                    - event_types
            example:
              webhook:
                url: https://hooks.example.com/workforce
                description: Payroll sync
                event_types:
                - leave_request.approved
                - shift.created
      responses:
        '201':
          description: The webhook, with its signing secret (this response only)
          content:
            application/json:
              schema:
                type: object
                required:
                - webhook
                properties:
                  webhook:
                    "$ref": "#/components/schemas/WebhooksWebhookWithSecret"
        '400':
          description: "`parameter_missing` — the body has no `webhook` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:webhooks` ("Required scope: write:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            The webhook was not saved. `errors` holds one sentence per failed
            validation, for example "URL must be a full http:// or https://
            address, such as https://example.com/webhook", "URL must not
            include a username or password — receivers verify the
            X-Webhook-Signature header instead", "URL must not include a
            #fragment (anything after #)", "URL has already been taken",
            "Event types can't be blank" or "Event types contains invalid
            event types: …".
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/WebhooksValidationMessages"
  "/webhooks/event_types":
    get:
      tags:
      - Webhooks
      summary: List the event types a webhook can subscribe to
      description: |
        `event_types` is the platform catalogue a webhook can subscribe to right
        now — exactly what `"*"` covers apart from the `person.*` lifecycle
        events — less the events waiting on a platform switch that is off (a
        subscription to one would hear nothing). The list is the same for every
        business.

        `plugin_event_types` lists the webhook events (declared with
        `outgoing_event`) of the plugins that can run for this business, each
        subscribable only by naming it. It is returned only to a caller who is
        an admin or super admin of the business; anyone else gets
        `event_types` alone (the key is absent, not empty).

        **Required Scopes:** `read:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:webhooks").
        This is the one action with no admin gate. A browser session is asked
        no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The subscribable event types
          content:
            application/json:
              schema:
                type: object
                required:
                - event_types
                properties:
                  event_types:
                    type: array
                    items:
                      type: string
                    example:
                    - shift.created
                    - shift_assignment.updated
                    - leave_request.approved
                    - attendance.missed
                  plugin_event_types:
                    type: array
                    description: Present only for an admin or super admin caller.
                    items:
                      type: object
                      required:
                      - event_type
                      - plugin_slug
                      - plugin_name
                      properties:
                        event_type:
                          type: string
                          description: '"plugin.<slug>.<event>" — the name to put
                            in a webhook''s `event_types`.'
                          example: plugin.expense_tracker.expense.approved
                        plugin_slug:
                          type: string
                          example: expense_tracker
                        plugin_name:
                          type: string
                          description: The plugin's name, else its slug.
                          example: Expense Tracker
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:webhooks`
            ("Required scope: read:webhooks").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/webhooks/verify":
    post:
      tags:
      - Webhooks
      summary: Check a URL before registering it as a webhook
      description: |
        Sends one signed test event (`event_type` "verification", signed with a
        throwaway secret) to `url` and reports whether the receiver answered
        2xx. Nothing is saved: no webhook, no delivery record. The request
        leaves our servers under the same guard real deliveries use (http or
        https only, a public host, 5 s to connect, 10 s to answer).

        **Required Scopes:** `write:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:webhooks")
        before anything is sent. The caller must also be an admin or super
        admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - url
              properties:
                url:
                  type: string
                  description: The address to test. Top-level, not wrapped in `webhook`.
                  example: https://hooks.example.com/workforce
      responses:
        '200':
          description: The receiver accepted the event
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - url
                - test_result
                properties:
                  message:
                    type: string
                    example: URL verification successful
                  url:
                    type: string
                  test_result:
                    "$ref": "#/components/schemas/WebhooksVerifyResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:webhooks` ("Required scope: write:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Either `url` was blank — `{"error": {"code": "validation_failed",
            "message": "URL is required"}}` and nothing is sent — or the
            verification failed. A failure carries `error.code`
            `validation_failed` with the reason as `error.message` (for
            example "HTTP 500", or "URL hostname did not resolve to a public
            address" when the guard refused the host), beside `message`
            ("URL verification failed"), `url` and `test_result`.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    message:
                      type: string
                      example: URL verification failed
                    url:
                      type: string
                    test_result:
                      "$ref": "#/components/schemas/WebhooksVerifyResult"
  "/webhooks/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Webhook ID
      schema:
        type: integer
    get:
      tags:
      - Webhooks
      summary: Get a webhook
      description: |
        One webhook of the caller's business, active or not, without its
        signing secret.

        **Required Scopes:** `read:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:webhooks")
        before the webhook is looked up. The caller must also be an admin or
        super admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The webhook
          content:
            application/json:
              schema:
                type: object
                required:
                - webhook
                properties:
                  webhook:
                    "$ref": "#/components/schemas/WebhooksWebhook"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:webhooks` ("Required scope: read:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no webhook with that id in the caller''s business
            ("Webhook not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Webhooks
      summary: Update a webhook
      description: |
        Changes any of the fields in `webhook`; the rest are left alone. The
        response never carries the signing secret, even one the body has just
        set. A `name` key is an alias for `description` when `description` is
        not sent. Event names already on the webhook stay valid even if they
        could no longer be added (a disabled plugin's event, an event whose
        platform switch is now off); only names being added are checked.

        **Required Scopes:** `write:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:webhooks")
        before the webhook is looked up. Switching a live webhook off —
        `active` false, `"0"`, `""` or null, anything that does not read as
        true, sent to a webhook that is on — stops every delivery to it, so it
        also asks `destructive:webhooks` on top of `write:webhooks`.
        Re-sending an off value to a webhook already off, switching one on,
        and every other edit need `write:webhooks` alone. The caller must also
        be an admin or super admin of the business. A browser session is
        asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - webhook
              properties:
                webhook:
                  "$ref": "#/components/schemas/WebhooksWebhookInput"
            example:
              webhook:
                description: Payroll sync (EU)
                event_types:
                - leave_request.approved
      responses:
        '200':
          description: The updated webhook (without its signing secret)
          content:
            application/json:
              schema:
                type: object
                required:
                - webhook
                properties:
                  webhook:
                    "$ref": "#/components/schemas/WebhooksWebhook"
        '400':
          description: "`parameter_missing` — the body has no `webhook` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:webhooks` ("Required scope: write:webhooks"), or the update switches a live webhook off and the token lacks `destructive:webhooks` ("Required scope: destructive:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no webhook with that id in the caller''s business
            ("Webhook not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            The change was not saved; `errors` holds one sentence per failed
            validation (the same messages as POST /webhooks).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/WebhooksValidationMessages"
    put:
      tags:
      - Webhooks
      summary: Update a webhook (PUT)
      description: |
        Same as PATCH /webhooks/{id}: the same body, gates and responses,
        including `destructive:webhooks` on top of `write:webhooks` for an
        update that switches a live webhook off.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - webhook
              properties:
                webhook:
                  "$ref": "#/components/schemas/WebhooksWebhookInput"
            example:
              webhook:
                description: Payroll sync (EU)
                event_types:
                - leave_request.approved
      responses:
        '200':
          description: The updated webhook (without its signing secret)
          content:
            application/json:
              schema:
                type: object
                required:
                - webhook
                properties:
                  webhook:
                    "$ref": "#/components/schemas/WebhooksWebhook"
        '400':
          description: "`parameter_missing` — the body has no `webhook` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:webhooks` ("Required scope: write:webhooks"), or the update switches a live webhook off and the token lacks `destructive:webhooks` ("Required scope: destructive:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no webhook with that id in the caller''s business
            ("Webhook not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            The change was not saved; `errors` holds one sentence per failed
            validation (the same messages as POST /webhooks).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/WebhooksValidationMessages"
    delete:
      tags:
      - Webhooks
      summary: Delete a webhook
      description: |
        Deletes the webhook and its delivery history. Deliveries stop at once.

        **Required Scopes:** `destructive:webhooks` — the area's delete level,
        asked instead of `write:webhooks` (a token holding only
        `write:webhooks` is refused). A token without it gets
        `403 insufficient_permissions` ("Required scope: destructive:webhooks")
        before the webhook is looked up. The caller must also be an admin or
        super admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Deleted; no body
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:webhooks` ("Required scope: destructive:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no webhook with that id in the caller''s business
            ("Webhook not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/webhooks/{id}/test":
    post:
      tags:
      - Webhooks
      summary: Send a test event to a webhook
      description: |
        Delivers one signed `test.event` to the webhook's URL now — one
        attempt, no retries — whether the webhook is active or not, and
        answers with the delivery record. The delivery is kept in the
        webhook's delivery log. A success records `last_triggered_at` and
        resets `consecutive_failures`. A failed test is answered `422` and
        never counted toward `consecutive_failures` — only real deliveries
        can switch a webhook off. No body is needed.

        **Required Scopes:** `write:webhooks`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:webhooks")
        before anything is sent. The caller must also be an admin or super
        admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Webhook ID
        schema:
          type: integer
      responses:
        '200':
          description: The receiver answered 2xx
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - test_result
                properties:
                  message:
                    type: string
                    example: Webhook test successful
                  test_result:
                    "$ref": "#/components/schemas/WebhooksTestResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:webhooks` ("Required scope: write:webhooks").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no webhook with that id in the caller''s business
            ("Webhook not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            The test delivery failed. Not the error envelope: the body is
            `message` ("Webhook test failed") and `test_result`, whose `error`
            says why ("HTTP 500", a network error, or the URL guard's reason).
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - test_result
                properties:
                  message:
                    type: string
                    example: Webhook test failed
                  test_result:
                    "$ref": "#/components/schemas/WebhooksTestResult"
  "/forecasts":
    get:
      tags:
      - Forecasts
      summary: List forecasts
      description: |
        The business's forecasts, active or not, latest `forecast_date` first,
        paginated, each with its full location row. Demand signals are not
        included here (see GET /forecasts/{id} or /forecasts/date_range).

        Optional filters: `location_id`, `source`, and a date window that
        applies only when BOTH `start_date` and `end_date` are sent
        (inclusive).

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts") before anything else is checked. Any
        member of the business may read. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Forecasts per page. Clamped to 1..100; a blank or non-numeric
          value reads as 0 and is clamped to 1.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      - name: location_id
        in: query
        description: Only forecasts for this location.
        schema:
          type: integer
      - name: source
        in: query
        description: Only forecasts with this `source` (see GET /forecasts/types).
        schema:
          type: string
          example: api
      - name: start_date
        in: query
        description: First day of the window (YYYY-MM-DD). Ignored unless `end_date`
          is sent too.
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: Last day of the window (YYYY-MM-DD). Ignored unless `start_date`
          is sent too.
        schema:
          type: string
          format: date
      responses:
        '200':
          description: One page of forecasts
          content:
            application/json:
              schema:
                type: object
                required:
                - forecasts
                - meta
                properties:
                  forecasts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ForecastsForecastWithLocation"
                  meta:
                    "$ref": "#/components/schemas/ForecastsListMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Forecasts
      summary: Create a forecast
      description: |
        Creates the forecast for one location on one date. A second forecast
        for the same location and date is refused (422 "Forecast date has
        already been taken") — update the existing one instead.

        `demand_signals`, when sent, are created at the new forecast's location
        and date (an `id` in them is ignored). A signal the model refuses does
        not undo the forecast: the response is still 201, with one message per
        refused signal in `demand_signal_errors`.

        **Required Scopes:** `write:shifts`. A token without it (or `admin`)
        gets `403 insufficient_permissions` ("Required scope: write:shifts")
        before anything else is checked. The caller must also be an admin or
        super admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - forecast
              properties:
                forecast:
                  allOf:
                  - "$ref": "#/components/schemas/ForecastsForecastInput"
                  - type: object
                    required:
                    - location_id
                    - forecast_date
                demand_signals:
                  type: array
                  description: Signals to create at this forecast's location and date.
                  items:
                    "$ref": "#/components/schemas/ForecastsNestedDemandSignalInput"
            example:
              forecast:
                location_id: 12
                forecast_date: '2026-10-15'
                confidence: 80
                hourly_predictions:
                - hour: 9
                  predicted_volume: 40
                  min_staff_needed: 2
                  optimal_staff_needed: 3
                  max_staff_needed: 4
                - hour: 10
                  predicted_volume: 65
                  min_staff_needed: 3
                  optimal_staff_needed: 4
                  max_staff_needed: 5
              demand_signals:
              - signal_type: event
                name: Stadium concert
                impact_factor: 1.35
      responses:
        '201':
          description: The forecast, with its location and the live demand signals
            at its location and date
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsForecastResponse"
        '400':
          description: "`parameter_missing` — the body has no `forecast` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — `location_id` is not one of the business''s
            locations ("Location not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Not saved. `errors` holds one sentence per failed validation —
            "Location can't be blank" (no `location_id`), "Forecast date can't
            be blank", "Forecast date has already been taken", "Confidence must
            be less than or equal to 100", "Hourly predictions hour must be
            between 0 and 23", and so on.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsValidationMessages"
  "/forecasts/date_range":
    get:
      tags:
      - Forecasts
      summary: Forecasts for one location over a date range
      description: |
        Every forecast (active or not) for one location from `start_date` to
        `end_date` inclusive, earliest first, not paginated. Each carries the
        live demand signals at that location on its date (highest
        `impact_factor` first); none carries its location row.

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts"). Any member of the business may read.
        A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: location_id
        in: query
        required: true
        description: A location of the caller's business (another business's id simply
          matches nothing).
        schema:
          type: integer
      - name: start_date
        in: query
        required: true
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        required: true
        schema:
          type: string
          format: date
      responses:
        '200':
          description: The forecasts in the range
          content:
            application/json:
              schema:
                type: object
                required:
                - forecasts
                - meta
                properties:
                  forecasts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ForecastsForecastWithSignals"
                  meta:
                    type: object
                    required:
                    - location_id
                    - start_date
                    - end_date
                    - total_count
                    properties:
                      location_id:
                        type: string
                        description: The `location_id` query value, echoed as sent
                          (a string).
                        example: '12'
                      start_date:
                        type: string
                        description: Echoed as sent.
                        example: '2026-10-01'
                      end_date:
                        type: string
                        description: Echoed as sent.
                        example: '2026-10-31'
                      total_count:
                        type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`validation_failed` — "location_id, start_date, and end_date
            are required" (one of them is blank).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/forecasts/types":
    get:
      tags:
      - Forecasts
      summary: Forecast sources
      description: |
        The values a forecast's `source` carries — `api` (written through this
        API) and `derived` (the autonomous baseline sweep). There is no
        forecast type: every forecast is a per-day staffing forecast, and
        `source` is the dimension GET /forecasts filters on. `source` is not
        validated against this list on write.

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts"). A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The sources
          content:
            application/json:
              schema:
                type: object
                required:
                - forecast_sources
                properties:
                  forecast_sources:
                    type: array
                    items:
                      type: string
                    example:
                    - api
                    - derived
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/forecasts/bulk_create":
    post:
      tags:
      - Forecasts
      summary: Create many forecasts in the background
      description: |
        Queues a background job that creates one forecast per item and answers
        202 at once; poll `status_url` (GET /bulk_operations/status/{job_id})
        for progress and per-item errors. Items are checked by the job, not
        here: an item needs `location_id` (one of the business's locations)
        and `forecast_date`.

        `options.conflict_resolution` decides what an item does when a
        forecast already exists for its location and date: `skip` (default —
        counted as processed, not successful), `overwrite` (the item's fields
        are written onto it), or `fail` (the item fails).
        `options.validation_mode`: `strict` (default) stops the whole
        operation at the first failed item — items already written stay
        written — and `lenient` records the failure and carries on.

        **Required Scopes:** `write:shifts`. A token without it (or `admin`)
        gets `403 insufficient_permissions` ("Required scope: write:shifts")
        before anything else is checked. A batch with
        `options.conflict_resolution` `overwrite` in which any item carries an
        `active` that does not read as true (false, `"0"`, `""`, null)
        switches existing forecasts off, so it also asks
        `destructive:scheduling` on top of `write:shifts`, and nothing is
        queued without it. The caller must also be an admin or super admin of
        the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - forecasts
              properties:
                forecasts:
                  type: array
                  minItems: 1
                  items:
                    "$ref": "#/components/schemas/ForecastsBulkItem"
                options:
                  "$ref": "#/components/schemas/ForecastsBulkOptions"
            example:
              forecasts:
              - location_id: 12
                forecast_date: '2026-10-15'
                confidence: 70
              - location_id: 12
                forecast_date: '2026-10-16'
                confidence: 65
              options:
                conflict_resolution: overwrite
                validation_mode: lenient
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsBulkAccepted"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts"), or the batch overwrites forecasts with `active` off and the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Nothing was queued.
            - `forecasts` was missing or empty: `{"error": {"code": "validation_failed", "message": "No forecasts provided"}}`.
            - The bulk operation could not be saved: `{"errors": ["…"]}`.
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/Error"
                - "$ref": "#/components/schemas/ForecastsValidationMessages"
        '503':
          description: |
            `service_unavailable` — the job could not be queued ("Could not
            schedule the bulk operation. Please try again."). The operation is
            saved as `failed` and returned beside the error as
            `bulk_operation`.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    bulk_operation:
                      "$ref": "#/components/schemas/ForecastsBulkOperation"
  "/forecasts/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Forecast ID
      schema:
        type: integer
    get:
      tags:
      - Forecasts
      summary: Get a forecast
      description: |
        One forecast of the caller's business (active or not), with its
        location row and the live demand signals at its location and date,
        highest `impact_factor` first.

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts") before the forecast is looked up. Any
        member of the business may read. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The forecast
          content:
            application/json:
              schema:
                type: object
                required:
                - forecast
                properties:
                  forecast:
                    "$ref": "#/components/schemas/ForecastsForecastDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no forecast with that id in the caller''s business
            ("Forecast not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Forecasts
      summary: Update a forecast
      description: |
        Changes any of the fields in `forecast`; the rest are left alone.

        Send `demand_signals` to edit the signals at the forecast's location
        and date (as they stand after this save): an element with `id` updates
        that signal — only one of this forecast's live signals; any other id is
        reported as "signal N: not found for this forecast" — and one without
        `id` creates a signal there. With `signals_strategy: "replace"`, every
        live signal at that location and date is deleted first and the sent
        ones are created. Signal writes the model refuses do not undo the
        forecast's save: the response is still 200, with one message per
        refused signal in `demand_signal_errors`.

        **Required Scopes:** `write:shifts`. A token without it (or `admin`)
        gets `403 insufficient_permissions` ("Required scope: write:shifts")
        before the forecast is looked up. An update that ends something also
        asks `destructive:scheduling` on top of `write:shifts`:
        - switching a live forecast off — `forecast.active` false, `"0"`, `""`
          or null, anything that does not read as true (an inactive forecast
          drops out of scheduling);
        - switching a live demand signal off through `demand_signals` (an
          element with `id` and an `active` that is present and does not read
          as true);
        - `signals_strategy: "replace"` when there are live signals at the
          location and date to delete.
        Every other edit, switching a forecast or signal on, and re-sending
        off to one already off need `write:shifts` alone. The caller must also
        be an admin or super admin of the business. A browser session is asked
        no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - forecast
              properties:
                forecast:
                  "$ref": "#/components/schemas/ForecastsForecastInput"
                demand_signals:
                  type: array
                  items:
                    "$ref": "#/components/schemas/ForecastsNestedDemandSignalInput"
                signals_strategy:
                  type: string
                  enum:
                  - replace
                  description: '"replace" deletes the live signals at the location
                    and date before creating the sent ones. Any other value (or none)
                    updates by `id` and creates the rest. Read only when `demand_signals`
                    is sent.'
            example:
              forecast:
                confidence: 75
              demand_signals:
              - id: 88
                impact_factor: 1.2
              - signal_type: promotion
                name: Weekend sale
                impact_factor: 1.15
      responses:
        '200':
          description: The updated forecast, with its location and the live demand
            signals at its location and date
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsForecastResponse"
        '400':
          description: "`parameter_missing` — the body has no `forecast` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts"), or the update ends something (see above) and the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` — no forecast with that id in the caller's business ("Forecast not found").
            - `not_found` — the sent `location_id` is not one of the business's locations ("Location not found").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The forecast was not saved; `errors` holds one sentence per
            failed validation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsValidationMessages"
    put:
      tags:
      - Forecasts
      summary: Update a forecast (PUT)
      description: |
        Same as PATCH /forecasts/{id}: the same body, gates and responses,
        including `destructive:scheduling` on top of `write:shifts` for an
        update that ends something.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - forecast
              properties:
                forecast:
                  "$ref": "#/components/schemas/ForecastsForecastInput"
                demand_signals:
                  type: array
                  items:
                    "$ref": "#/components/schemas/ForecastsNestedDemandSignalInput"
                signals_strategy:
                  type: string
                  enum:
                  - replace
                  description: '"replace" deletes the live signals at the location
                    and date before creating the sent ones. Any other value (or none)
                    updates by `id` and creates the rest. Read only when `demand_signals`
                    is sent.'
            example:
              forecast:
                confidence: 75
              demand_signals:
              - id: 88
                impact_factor: 1.2
              - signal_type: promotion
                name: Weekend sale
                impact_factor: 1.15
      responses:
        '200':
          description: The updated forecast, with its location and the live demand
            signals at its location and date
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsForecastResponse"
        '400':
          description: "`parameter_missing` — the body has no `forecast` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts"), or the update ends something (see above) and the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` — no forecast with that id in the caller's business ("Forecast not found").
            - `not_found` — the sent `location_id` is not one of the business's locations ("Location not found").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The forecast was not saved; `errors` holds one sentence per
            failed validation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ForecastsValidationMessages"
    delete:
      tags:
      - Forecasts
      summary: Delete a forecast
      description: |
        Deletes the forecast. The demand signals at its location and date are
        not deleted — they do not belong to it.

        **Required Scopes:** `destructive:scheduling` — the area's delete
        level, asked instead of `write:shifts` (a token holding only
        `write:shifts` is refused). A token without it (or `admin`) gets
        `403 insufficient_permissions` ("Required scope:
        destructive:scheduling") before the forecast is looked up, so a
        missing id is a 403, not a 404. The caller must also be an admin or
        super admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Deleted; no body
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no forecast with that id in the caller''s business
            ("Forecast not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/demand_signals":
    get:
      tags:
      - Demand Signals
      summary: List demand signals
      description: |
        The business's demand signals, active or not, latest `signal_date`
        first (then newest id first, so paging is stable), paginated, each
        with its full location row.

        Optional filters: `forecast_id` (the signals at that forecast's
        location and date, active or not), `signal_type`, `location_id`, and a
        date window that applies only when BOTH `start_date` and `end_date`
        are sent (inclusive). Filters combine.

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts") before anything else is checked. Any
        member of the business may read. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Signals per page. Clamped to 1..100; a blank or non-numeric value
          reads as 0 and is clamped to 1.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      - name: forecast_id
        in: query
        description: Only the signals at this forecast's location and date. 404 when
          the forecast is not in the caller's business.
        schema:
          type: integer
      - name: signal_type
        in: query
        description: Only signals of this type (see GET /demand_signals/types).
        schema:
          type: string
          enum:
          - event
          - weather
          - holiday
          - promotion
          - sale
          - seasonal
          - external
      - name: location_id
        in: query
        description: Only signals at this location.
        schema:
          type: integer
      - name: start_date
        in: query
        description: First day of the window (YYYY-MM-DD). Ignored unless `end_date`
          is sent too.
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: Last day of the window (YYYY-MM-DD). Ignored unless `start_date`
          is sent too.
        schema:
          type: string
          format: date
      responses:
        '200':
          description: One page of demand signals
          content:
            application/json:
              schema:
                type: object
                required:
                - demand_signals
                - meta
                properties:
                  demand_signals:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DemandSignalsSignalWithLocation"
                  meta:
                    "$ref": "#/components/schemas/DemandSignalsListMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — `forecast_id` names no forecast in the caller''s
            business ("Forecast not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Demand Signals
      summary: Create a demand signal
      description: |
        Creates a signal for one location on one date. `location_id`,
        `signal_type` and `signal_date` are required, and `name` too unless
        `signal_type` is "weather". `impact_factor` defaults to 1.0 (no
        effect); `value` is accepted as its older name.

        **Required Scopes:** `write:shifts`. A token without it (or `admin`)
        gets `403 insufficient_permissions` ("Required scope: write:shifts")
        before anything else is checked. The caller must also be an admin or
        super admin of the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - demand_signal
              properties:
                demand_signal:
                  allOf:
                  - "$ref": "#/components/schemas/DemandSignalsSignalInput"
                  - type: object
                    required:
                    - location_id
                    - signal_type
                    - signal_date
            example:
              demand_signal:
                location_id: 12
                signal_type: event
                signal_date: '2026-10-15'
                name: Stadium concert
                impact_factor: 1.35
      responses:
        '201':
          description: The signal, with its location row
          content:
            application/json:
              schema:
                type: object
                required:
                - demand_signal
                properties:
                  demand_signal:
                    "$ref": "#/components/schemas/DemandSignalsSignalWithLocation"
        '400':
          description: "`parameter_missing` — the body has no `demand_signal` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — `location_id` is not one of the business''s
            locations ("Location not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Not saved. `errors` holds one sentence per failed validation —
            "Location can't be blank" (no `location_id`), "Signal type is not
            included in the list", "Signal date can't be blank", "Name can't be
            blank", "Impact factor must be greater than 0", and so on.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/DemandSignalsValidationMessages"
  "/demand_signals/types":
    get:
      tags:
      - Demand Signals
      summary: Demand signal types
      description: |
        The values accepted for a signal's `signal_type`.

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts"). A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The signal types
          content:
            application/json:
              schema:
                type: object
                required:
                - signal_types
                properties:
                  signal_types:
                    type: array
                    items:
                      type: string
                    example:
                    - event
                    - weather
                    - holiday
                    - promotion
                    - sale
                    - seasonal
                    - external
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/demand_signals/bulk_create":
    post:
      tags:
      - Demand Signals
      summary: Create many demand signals in the background
      description: |
        Queues a background job that creates one signal per item and answers
        202 at once; poll `status_url` (GET /bulk_operations/status/{job_id})
        for progress and per-item errors. Items are checked by the job, not
        here: an item needs `location_id` (one of the business's locations),
        `signal_type` and `signal_date`, and `name` unless the type is
        "weather". `value` is not accepted here — send `impact_factor`.

        An item matches an existing signal when location, date, type and name
        are all equal. `options.conflict_resolution` decides what it does
        then: `skip` (default — counted as processed, not successful),
        `overwrite` (the item's fields are written onto it), or `fail` (the
        item fails). `options.validation_mode`: `strict` (default) stops the
        whole operation at the first failed item — items already written stay
        written — and `lenient` records the failure and carries on.

        **Required Scopes:** `write:shifts`. A token without it (or `admin`)
        gets `403 insufficient_permissions` ("Required scope: write:shifts")
        before anything else is checked. A batch with
        `options.conflict_resolution` `overwrite` in which any item carries an
        `active` that does not read as true (false, `"0"`, `""`, null)
        switches existing signals off, so it also asks
        `destructive:scheduling` on top of `write:shifts`, and nothing is
        queued without it. The caller must also be an admin or super admin of
        the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - demand_signals
              properties:
                demand_signals:
                  type: array
                  minItems: 1
                  items:
                    "$ref": "#/components/schemas/DemandSignalsBulkItem"
                options:
                  "$ref": "#/components/schemas/DemandSignalsBulkOptions"
            example:
              demand_signals:
              - location_id: 12
                signal_type: holiday
                signal_date: '2026-12-24'
                name: Christmas Eve
                impact_factor: 1.6
              - location_id: 12
                signal_type: weather
                signal_date: '2026-12-26'
                impact_factor: 0.8
              options:
                validation_mode: lenient
      responses:
        '202':
          description: Queued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/DemandSignalsBulkAccepted"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts"), or the batch overwrites signals with `active` off and the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Nothing was queued.
            - `demand_signals` was missing or empty: `{"error": {"code": "validation_failed", "message": "No demand signals provided"}}`.
            - The bulk operation could not be saved: `{"errors": ["…"]}`.
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/Error"
                - "$ref": "#/components/schemas/DemandSignalsValidationMessages"
        '503':
          description: |
            `service_unavailable` — the job could not be queued ("Could not
            schedule the bulk operation. Please try again."). The operation is
            saved as `failed` and returned beside the error as
            `bulk_operation`.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    bulk_operation:
                      "$ref": "#/components/schemas/DemandSignalsBulkOperation"
  "/demand_signals/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Demand signal ID
      schema:
        type: integer
    get:
      tags:
      - Demand Signals
      summary: Get a demand signal
      description: |
        One signal of the caller's business, active or not, with its location
        row.

        **Required Scopes:** `read:shifts`. A token without it (or
        `read:own_shifts`, `admin`, or `read:all`) gets `403 insufficient_permissions`
        ("Required scope: read:shifts") before the signal is looked up. Any
        member of the business may read. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The signal
          content:
            application/json:
              schema:
                type: object
                required:
                - demand_signal
                properties:
                  demand_signal:
                    "$ref": "#/components/schemas/DemandSignalsSignalWithLocation"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` — the token lacks `read:shifts`
            ("Required scope: read:shifts").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no signal with that id in the caller''s business
            ("Demand signal not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Demand Signals
      summary: Update a demand signal
      description: |
        Changes any of the fields in `demand_signal`; the rest are left alone.
        `value` is accepted as the older name of `impact_factor` when
        `impact_factor` is blank. A sent `location_id` must be one of the
        business's locations; a blank one is not checked here and fails the
        save.

        **Required Scopes:** `write:shifts`. A token without it (or `admin`)
        gets `403 insufficient_permissions` ("Required scope: write:shifts")
        before the signal is looked up. Switching a live signal off —
        `active` false, `"0"`, `""` or null, anything that does not read as
        true, sent to a signal that is on — takes it out of every forecast it
        fed, so it also asks `destructive:scheduling` on top of
        `write:shifts`. Re-sending an off value to a signal already off,
        switching one on, and every other edit need `write:shifts` alone. The
        caller must also be an admin or super admin of the business. A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - demand_signal
              properties:
                demand_signal:
                  "$ref": "#/components/schemas/DemandSignalsSignalInput"
            example:
              demand_signal:
                name: Cup final
                impact_factor: 1.5
      responses:
        '200':
          description: The updated signal, with its location row
          content:
            application/json:
              schema:
                type: object
                required:
                - demand_signal
                properties:
                  demand_signal:
                    "$ref": "#/components/schemas/DemandSignalsSignalWithLocation"
        '400':
          description: "`parameter_missing` — the body has no `demand_signal` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts"), or the update switches a live signal off and the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` — no signal with that id in the caller's business ("Demand signal not found").
            - `not_found` — the sent `location_id` is not one of the business's locations ("Location not found").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The change was not saved; `errors` holds one sentence per failed
            validation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/DemandSignalsValidationMessages"
    put:
      tags:
      - Demand Signals
      summary: Update a demand signal (PUT)
      description: |
        Same as PATCH /demand_signals/{id}: the same body, gates and
        responses, including `destructive:scheduling` on top of `write:shifts`
        for an update that switches a live signal off.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - demand_signal
              properties:
                demand_signal:
                  "$ref": "#/components/schemas/DemandSignalsSignalInput"
            example:
              demand_signal:
                name: Cup final
                impact_factor: 1.5
      responses:
        '200':
          description: The updated signal, with its location row
          content:
            application/json:
              schema:
                type: object
                required:
                - demand_signal
                properties:
                  demand_signal:
                    "$ref": "#/components/schemas/DemandSignalsSignalWithLocation"
        '400':
          description: "`parameter_missing` — the body has no `demand_signal` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:shifts` ("Required scope: write:shifts"), or the update switches a live signal off and the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` — no signal with that id in the caller's business ("Demand signal not found").
            - `not_found` — the sent `location_id` is not one of the business's locations ("Location not found").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The change was not saved; `errors` holds one sentence per failed
            validation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/DemandSignalsValidationMessages"
    delete:
      tags:
      - Demand Signals
      summary: Delete a demand signal
      description: |
        Deletes the signal; it stops feeding the forecasts at its location and
        date.

        **Required Scopes:** `destructive:scheduling` — the area's delete
        level, asked instead of `write:shifts` (a token holding only
        `write:shifts` is refused). A token without it (or `admin`) gets
        `403 insufficient_permissions` ("Required scope:
        destructive:scheduling") before the signal is looked up. The caller
        must also be an admin or super admin of the business. A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Deleted; no body
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:scheduling` ("Required scope: destructive:scheduling").
            - `forbidden` — the caller is not an admin or super admin of the business ("Admin access required").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no signal with that id in the caller''s business
            ("Demand signal not found").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/export_files":
    get:
      tags:
      - Export Files
      summary: List exports
      description: |
        The exports the caller can see (their own, or every export in the
        business for an admin), newest first, paginated.

        **Required Scopes:** none for the list itself, but every row carries
        its download link, so a token sees only the exports whose type's
        scope it holds in the wide form (`read:users`, `read:shifts`,
        `read:attendance`, `read:leave_requests`, or `admin`); the others are
        left out of the rows and the counts, never refused. A browser session
        sees every type. The caller must be a manager, admin or super admin of
        the business. Shifts and shift-assignments exports are left out of
        the rows and the counts while Shifts & Scheduling is off for the
        business or not open to the caller — the same rule that answers 404
        when one is read.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Exports per page. Clamped to 1..100; a blank or non-numeric value
          reads as 0 and is clamped to 1. Missing, or sent as a list, reads as 10.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of exports
          content:
            application/json:
              schema:
                type: object
                required:
                - export_files
                - meta
                properties:
                  export_files:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ExportFilesExportFile"
                  meta:
                    type: object
                    required:
                    - current_page
                    - total_pages
                    - total_count
                    properties:
                      current_page:
                        type: integer
                      total_pages:
                        type: integer
                      total_count:
                        type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`forbidden` — the caller is not a manager, admin or super
            admin of the business ("Exporting business-wide records requires manager
            access").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
    post:
      tags:
      - Export Files
      summary: Start an export
      description: |
        Starts an export of `export_file.export_type` in `export_file.format`
        (default csv) and answers 201 at once with `status_url` to poll. The
        caller is recorded as the export's requester. No export takes
        `filters` or `options` — the file holds every row of its type — and a
        non-empty one, at the top level or inside `export_file`, is refused
        with `422 unsupported_parameter` before anything is saved. The typed routes (POST /export_files/shifts,
        /users, /attendance_records, /leave_requests, /shift_assignments) start
        the same exports.

        **Required Scopes:** the export type's — `read:users` for `users` /
        `employees`, `read:shifts` for `shifts` / `shift_assignments` /
        `assignments`, `read:attendance` for `attendance_records`,
        `read:leave_requests` for `leave_requests` — in the wide form only
        (or `admin`). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:…") and nothing is saved. The caller must be a
        manager, admin or super admin, and an admin or super admin for
        `users` / `employees`; `shifts` / `shift_assignments` / `assignments`
        also need Shifts & Scheduling enabled and open to the caller. A browser session is asked no
        scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - export_file
              properties:
                export_file:
                  type: object
                  required:
                  - export_type
                  properties:
                    export_type:
                      type: string
                      enum:
                      - shifts
                      - users
                      - attendance_records
                      - leave_requests
                      - shift_assignments
                      - assignments
                      - employees
                      description: "`employees` and `assignments` are older names
                        for `users` and `shift_assignments`."
                    format:
                      type: string
                      enum:
                      - csv
                      - json
                      - xlsx
                      default: csv
            example:
              export_file:
                export_type: leave_requests
                format: xlsx
      responses:
        '201':
          "$ref": "#/components/responses/ExportFilesQueued"
        '400':
          description: "`parameter_missing` — the body has no `export_file` object."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access"); or the type is `users` / `employees` and the caller is not an admin or super admin ("Exporting the employee directory requires administrator access"); or the type is `shifts` / `shift_assignments` / `assignments` and Shifts & Scheduling is off ("Shifts & Scheduling is not enabled for this business") or not open to the caller ("You don't have access to Shifts & Scheduling. Please contact your administrator.").
            - `insufficient_permissions` — the token lacks the type's scope ("Required scope: read:users", "read:shifts", "read:attendance" or "read:leave_requests").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '422':
          "$ref": "#/components/responses/ExportFilesNotQueued"
        '503':
          "$ref": "#/components/responses/ExportFilesNotScheduled"
  "/export_files/users":
    post:
      tags:
      - Export Files
      summary: Export the employee directory
      description: |
        Starts an export of every person in the business — ID, Name, Email,
        Job Title, Department, Hire Date, Active — and answers 201 at once
        with `status_url` to poll.

        **Required Scopes:** `read:users` (wide; or `admin`). A token without
        it gets `403 insufficient_permissions` ("Required scope: read:users")
        and nothing is saved. The caller must be an admin or super admin of
        the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ExportFilesTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/ExportFilesQueued"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access"), or is a manager but not an admin or super admin ("Exporting the employee directory requires administrator access").
            - `insufficient_permissions` — the token lacks `read:users` ("Required scope: read:users").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '422':
          "$ref": "#/components/responses/ExportFilesNotQueued"
        '503':
          "$ref": "#/components/responses/ExportFilesNotScheduled"
  "/export_files/shifts":
    post:
      tags:
      - Export Files
      summary: Export shifts
      description: |
        Starts an export of the business's shifts — ID, Name, Start Time, End
        Time, Status, Shift Type, Location, Required Staff, Published, Notes —
        and answers 201 at once with `status_url` to poll. The file holds only
        the shifts the requester oversees as a scheduler, worked out when the
        export is started (every shift for a caller who oversees all of
        them).

        **Required Scopes:** `read:shifts`, wide (or `admin`) —
        `read:own_shifts` does not stand in. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:shifts") and
        nothing is saved. The caller must be a manager, admin or super admin,
        and Shifts & Scheduling must be enabled for the business and open to
        the caller. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ExportFilesTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/ExportFilesQueued"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access"), or Shifts & Scheduling is off ("Shifts & Scheduling is not enabled for this business") or not open to the caller ("You don't have access to Shifts & Scheduling. Please contact your administrator.").
            - `insufficient_permissions` — the token lacks `read:shifts` ("Required scope: read:shifts").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '422':
          "$ref": "#/components/responses/ExportFilesNotQueued"
        '503':
          "$ref": "#/components/responses/ExportFilesNotScheduled"
  "/export_files/shift_assignments":
    post:
      tags:
      - Export Files
      summary: Export shift assignments
      description: |
        Starts an export of every shift assignment in the business — ID,
        Employee, Shift, Shift Start, Shift End, Status, Assignment Type,
        Scheduled Hours, Hours Worked, Total Cost — and answers 201 at once
        with `status_url` to poll.

        **Required Scopes:** `read:shifts`, wide (or `admin`) —
        `read:own_shifts` does not stand in. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:shifts") and
        nothing is saved. The caller must be an admin or super admin (the file
        carries each assignment's per-employee cost), and Shifts & Scheduling
        must be enabled for the business and open to the caller. A browser
        session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ExportFilesTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/ExportFilesQueued"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access"), is a manager but not an admin ("Shift assignment exports, with their per-employee cost, are limited to administrators. Managers can export their own people's assignments from Shifts & Scheduling › Reports › Shift Assignments."), or Shifts & Scheduling is off ("Shifts & Scheduling is not enabled for this business") or not open to the caller ("You don't have access to Shifts & Scheduling. Please contact your administrator.").
            - `insufficient_permissions` — the token lacks `read:shifts` ("Required scope: read:shifts").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '422':
          "$ref": "#/components/responses/ExportFilesNotQueued"
        '503':
          "$ref": "#/components/responses/ExportFilesNotScheduled"
  "/export_files/attendance_records":
    post:
      tags:
      - Export Files
      summary: Export attendance records
      description: |
        Starts an export of every attendance record in the business — ID,
        Employee, Shift, Check In, Check Out, Status, Work Mode, Punch Source,
        Notes — and answers 201 at once with `status_url` to poll.

        **Required Scopes:** `read:attendance`, wide (or `admin`) —
        `read:own_attendance` does not stand in. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:attendance") and
        nothing is saved. The caller must be an admin or super admin of the
        business, as for the web's bulk attendance export; sample punches and
        unapproved missing-punch requests are left out. A browser session is
        asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ExportFilesTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/ExportFilesQueued"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not an admin or super admin ("Attendance exports are limited to administrators. Managers can export their own people's time cards from Time & Attendance › All Records › Time Card Report.").
            - `insufficient_permissions` — the token lacks `read:attendance` ("Required scope: read:attendance").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '422':
          "$ref": "#/components/responses/ExportFilesNotQueued"
        '503':
          "$ref": "#/components/responses/ExportFilesNotScheduled"
  "/export_files/leave_requests":
    post:
      tags:
      - Export Files
      summary: Export leave requests
      description: |
        Starts an export of every leave request in the business — ID,
        Employee, Leave Type, Start Date, End Date, Half Day, Hours Requested
        (the hours the request takes), Status (as the screens label it:
        Pending approval, Special approval, Approved, Denied, Cancelled),
        Requested On, Notes — and answers 201 at once with `status_url` to poll.

        **Required Scopes:** `read:leave_requests`, wide (or `admin`) —
        `read:own_leave_requests` does not stand in. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:leave_requests")
        and nothing is saved. The caller must be an admin or super admin of
        the business. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ExportFilesTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/ExportFilesQueued"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not an admin or super admin ("Leave request exports are limited to administrators. Managers can export their own people's requests from Leave Management › Leave Requests › Export.").
            - `insufficient_permissions` — the token lacks `read:leave_requests` ("Required scope: read:leave_requests").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '422':
          "$ref": "#/components/responses/ExportFilesNotQueued"
        '503':
          "$ref": "#/components/responses/ExportFilesNotScheduled"
  "/export_files/status/{job_id}":
    get:
      tags:
      - Export Files
      summary: Progress of an export
      description: |
        The export started with this `job_id` and how far it has got.
        `progress` is 0 until the file is ready and 100 once it is — show an
        indeterminate state while `status` is `processing`.

        **Required Scopes:** the scope of the export's type (see POST
        /export_files), wide only (or `admin`). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:…"). The caller
        must be a manager, admin or super admin, and must be able to see the
        export (their own, or any for an admin). A browser session is asked
        no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: job_id
        in: path
        required: true
        description: The `job_id` the creating call returned.
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: The export and its progress
          content:
            application/json:
              schema:
                type: object
                required:
                - export_file
                - status
                - progress
                - file_size
                - completed_at
                - download_url
                properties:
                  export_file:
                    "$ref": "#/components/schemas/ExportFilesExportFile"
                  status:
                    type: string
                    enum:
                    - pending
                    - processing
                    - completed
                    - failed
                    - cancelled
                  progress:
                    type: integer
                    enum:
                    - 0
                    - 100
                  file_size:
                    type: string
                    nullable: true
                    description: Human-readable size ("12.4 KB") once the file exists,
                      else null.
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  download_url:
                    type: string
                    nullable: true
                    description: The path of GET /export_files/{id}/download once
                      the file can be downloaded (completed and not expired), else
                      null.
                    example: "/api/v1/export_files/41/download"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access").
            - `insufficient_permissions` — the token lacks the export type's scope ("Required scope: read:…").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '404':
          description: |
            `not_found` ("Export file not found") — no export the caller can see
            has that `job_id`, or it is a shifts or shift-assignments export
            and Shifts & Scheduling is off for the business or not open to the
            caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/export_files/{id}":
    get:
      tags:
      - Export Files
      summary: Get an export
      description: |
        One export the caller can see.

        **Required Scopes:** the scope of the export's type (see POST
        /export_files), wide only (or `admin`). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:…"). The caller
        must be a manager, admin or super admin. A browser session is asked
        no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/ExportFilesId"
      responses:
        '200':
          description: The export
          content:
            application/json:
              schema:
                type: object
                required:
                - export_file
                properties:
                  export_file:
                    "$ref": "#/components/schemas/ExportFilesExportFile"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access").
            - `insufficient_permissions` — the token lacks the export type's scope ("Required scope: read:…").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '404':
          description: |
            `not_found` ("Export file not found") — no export the caller can see
            has that id, or it is a shifts or shift-assignments export and
            Shifts & Scheduling is off for the business or not open to the
            caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/export_files/{id}/download":
    get:
      tags:
      - Export Files
      summary: Get a link to a finished export's file
      description: |
        Answers with a signed link to the file, not the file itself. The link
        is a relative path, good for one hour from this call; the file is kept
        for seven days after the export completes (`expires_at`). Ask again
        for a fresh link.

        **Required Scopes:** the scope of the export's type (see POST
        /export_files), wide only (or `admin`). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:…"). The caller
        must be a manager, admin or super admin. A browser session is asked
        no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/ExportFilesId"
      responses:
        '200':
          description: The download link and the file's details
          content:
            application/json:
              schema:
                type: object
                required:
                - download_url
                - file_name
                - content_type
                - file_size
                - file_size_formatted
                - expires_at
                properties:
                  download_url:
                    type: string
                    description: A signed path to the file, valid for one hour.
                    example: "/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsi…/leave_requests_20261010_101500.xlsx?disposition=attachment"
                  file_name:
                    type: string
                    example: leave_requests_20261010_101500.xlsx
                  content_type:
                    type: string
                    example: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
                  file_size:
                    type: integer
                    description: Bytes.
                  file_size_formatted:
                    type: string
                    example: 12.4 KB
                  expires_at:
                    type: string
                    format: date-time
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `forbidden` — the caller is not a manager, admin or super admin ("Exporting business-wide records requires manager access").
            - `insufficient_permissions` — the token lacks the export type's scope ("Required scope: read:…").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ExportFilesForbidden"
        '404':
          description: |
            `not_found` ("Export file not found") — no export the caller can see
            has that id, or it is a shifts or shift-assignments export and
            Shifts & Scheduling is off for the business or not open to the
            caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '410':
          description: '`gone` — "Export file has expired and is no longer available";
            `expires_at` is returned beside the error.'
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    expires_at:
                      type: string
                      format: date-time
        '422':
          description: |
            `validation_failed` — "Export file is not ready for download": the
            export has not completed (its `status` is returned beside the
            error), or it completed without a file.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/Error"
                - type: object
                  properties:
                    status:
                      type: string
                      enum:
                      - pending
                      - processing
                      - failed
                      - cancelled
  "/frontline_execution/locations":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The "Which locations" store picker for the intake-request form (with
        search + pagination)
      description: |
        Returns the tenant's active physical sites as `{ id, name }`, ordered by name —
        the store list the "New request" form's "Which locations" multi-select offers.
        The list is not span-bounded: every member sees every store (a store can ask
        for store-facing work too).

        `q` narrows by name (case-insensitive substring, LIKE-escaped) — the server-side
        form of the web picker's client-side typeahead. `page` and `limit` page the
        result; the standard `meta` envelope carries the true total, so every site is
        reachable even in a tenant with more stores than the web picker's render cap.
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Narrow the list by location name (case-insensitive substring).
        example: market
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
        description: Page number (default 1).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (1–100, default 25).
      responses:
        '200':
          description: The active physical sites for the picker, ordered by name.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineExecutionLocationsResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks an execution read scope (`insufficient_permissions`),
            or the Campaigns surface is off (`surface_disabled`).
  "/frontline_execution/config":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The intake-request form's config — the category dropdown options
      description: |
        Returns the option lists a client needs to render the "New request" form. Today
        that is `categories`: the tenant's active publication taxonomy ("MAP Retail",
        "Planogram", "Safety") a requester tags their ask with, reusing the SAME
        active-only, ordered scope the web request form's category select binds to — so
        the app and the browser can never offer a different set of categories.

        Retired (inactive) categories are excluded: they stay on the campaigns they
        already label but are never offered for something new. Every member reaches this
        (a store can ask for store-facing work too); it is not span-bounded. The list is
        a small, bounded taxonomy, so it is returned unpaginated.
      responses:
        '200':
          description: The intake-request form's config (the category dropdown options).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineExecutionRequestFormConfig"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks an execution read scope (`insufficient_permissions`),
            or the Campaigns surface is off (`surface_disabled`).
  "/frontline_execution/requests/{id}":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: One intake request in full (adapts to the caller's lens)
      description: |
        Returns everything the web request detail shows for one intake request,
        adapted to who is asking:

        - **`request`** — the ask itself (title, instructions, priority, work
          type, department, audience, category, desired window, estimated
          effort, target-store count, attachments), the decision trail
          (`decided_by` / `decided_at` / `decline_reason`), whether it is routed
          through a configured approval workflow (`under_engine_review`), and the
          draft campaign it minted once approved (`campaign`).
        - **`viewer`** — the caller's lens (`is_requester` / `is_gatekeeper`) and
          the three request-action flags (`can_approve`, `can_decline`,
          `can_withdraw`). These mirror the web's own "show this button" truth;
          the write endpoints remain the authority.
      parameters:
      - name: id
        in: path
        required: true
        description: The intake request (Execution::CampaignRequest) id.
        schema:
          type: integer
      responses:
        '200':
          description: The request, with the viewer block for the caller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/FrontlineRequestDetail"
                  viewer:
                    "$ref": "#/components/schemas/FrontlineRequestViewer"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the caller lacks the required scope (`insufficient_permissions`), the
            Campaigns surface is turned off (`surface_disabled`), or the caller is
            neither the submitter nor a gatekeeper (`forbidden`).
        '404':
          description: No such request in this business (`not_found`).
  "/frontline_execution/requests/{id}/approve":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Approve an intake request (mints a draft campaign)
      description: |
        Approves the request: it moves to `approved`, the caller is stamped as
        `decided_by`, and a pre-filled DRAFT `Execution::Campaign` is minted from
        the ask and linked as `request.campaign`.

        Returns the decided request in the same `{request, viewer}` shape the
        detail endpoint returns — now approved (so `viewer.can_approve` is false)
        and carrying the minted draft under `request.campaign`, so a client can
        render the new state and open the draft without a second round-trip.

        Refused (`409`) when the request was already decided or is no longer
        pending (`already_decided` — including losing a race to another gatekeeper
        or the approval engine), or when a configured approval workflow owns the
        decision (`engine_owned`). Refused (`422`, `invalid`) when the draft can't
        be built.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The intake request (Execution::CampaignRequest) to approve.
      responses:
        '200':
          description: Approved. Returns the decided request and the caller's viewer
            block.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      target_locations:
                        type: array
                        description: The stores the requester named directly, resolved
                          to id + name and ordered by name. Empty when the request
                          names an audience or every location instead of a direct
                          list. Tenant-scoped — a store id from another business is
                          never surfaced here.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 42
                            name:
                              type: string
                              example: Store 042 - Downtown
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller is not in the campaign-author
            gatekeeper tier (`forbidden`).
        '404':
          description: No such request in this business (`not_found`).
        '409':
          description: 'The request can''t be decided here: `already_decided` (already
            decided, no longer pending, or a concurrent decision won) or `engine_owned`
            (a configured approval workflow owns the decision).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
        '422':
          description: The draft campaign could not be built (`invalid`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
  "/frontline_execution/requests/{id}/decline":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Decline an intake request (reason required)
      description: |
        Declines the request: it moves to `declined`, the caller is stamped as
        `decided_by`, and the `decline_reason` is stored and sent back to the
        requester.

        The `decline_reason` is **required** — it is the only thing the person who
        asked sees, so a reason-free decline leaves them nothing to fix and
        resubmit. A blank, non-string, or shorter-than-4-character reason is
        refused with `reason_required` and the request stays pending.

        Returns the decided request in the `{request, viewer}` shape (now declined,
        carrying the `decline_reason`).

        Refused (`409`) when already decided (`already_decided`) or owned by a
        configured approval workflow (`engine_owned`).

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:frontline_execution` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The intake request (Execution::CampaignRequest) to decline.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - decline_reason
              properties:
                decline_reason:
                  type: string
                  minLength: 4
                  description: Why the request is declined (required, ≥ 4 chars).
                    Shown to the requester on their request and in the notification.
                  example: Already covered by a live campaign — no separate work needed.
      responses:
        '200':
          description: Declined. Returns the decided request and the caller's viewer
            block.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      target_locations:
                        type: array
                        description: The stores the requester named directly, resolved
                          to id + name and ordered by name. Empty when the request
                          names an audience or every location instead of a direct
                          list. Tenant-scoped — a store id from another business is
                          never surfaced here.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 42
                            name:
                              type: string
                              example: Store 042 - Downtown
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller is not in the campaign-author
            gatekeeper tier (`forbidden`).
        '404':
          description: No such request in this business (`not_found`).
        '409':
          description: 'The request can''t be decided here: `already_decided` or `engine_owned`
            (a configured approval workflow owns the decision).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
        '422':
          description: 'The decline was refused: `reason_required` (no/blank/too-short
            reason) or `invalid`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RequestDecisionError"
  "/frontline_execution/requests/{id}/withdraw":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Withdraw your own pending intake request
      description: |
        Withdraws the request: it moves to `withdrawn` and the caller is stamped
        as `decided_by` / `decided_at`. Any live (pending or escalated) approval
        request a configured workflow opened for it is cancelled, so reviewers
        stop seeing an abandoned ask. Nobody is notified — nothing was decided.

        Only the request's submitter may withdraw it, and only while it is still
        pending — exactly the condition `viewer.can_withdraw` on the detail
        endpoint reports, so a client that honours that flag never sees a refusal.

        Returns the request in the same `{request, viewer}` shape the detail
        endpoint returns — now `withdrawn`, so `viewer.can_withdraw` is false.
        No request body.

        Delete-level (owner decision 2026-10-04): requires the `destructive:frontline_execution`
        scope. A personal token cannot hold it: do this in MangoApps instead.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The intake request (Execution::CampaignRequest) to withdraw.
      responses:
        '200':
          description: Withdrawn. Returns the request and the caller's viewer block.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    type: object
                    description: One intake request in full. Decision/campaign fields
                      fill in as it moves through the funnel.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Re-check cold chain after firmware fix
                      status:
                        type: string
                        description: submitted / approved / declined / withdrawn.
                        example: submitted
                      priority:
                        type: string
                        example: important
                      work_type:
                        type: string
                        nullable: true
                        example: task
                      work_type_label:
                        type: string
                        nullable: true
                        example: Task
                      instructions:
                        type: string
                        nullable: true
                        example: Read both pharmacy fridges again and photograph the
                          panel.
                      note:
                        type: string
                        nullable: true
                      estimated_duration_minutes:
                        type: integer
                        nullable: true
                        example: 15
                      desired_start_on:
                        type: string
                        format: date
                        nullable: true
                      desired_end_on:
                        type: string
                        format: date
                        nullable: true
                      desired_publish_on:
                        type: string
                        format: date
                        nullable: true
                      category:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 7
                          name:
                            type: string
                            example: Compliance
                      department:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Pharmacy
                      audience:
                        type: object
                        nullable: true
                        description: The requested audience, or null ("Reviewer's
                          call" on the web).
                        properties:
                          id:
                            type: integer
                            example: 12
                          name:
                            type: string
                            example: 5 pharmacy stores
                      target_location_count:
                        type: integer
                        description: How many stores the requester named directly
                          (0 when they named an audience or left it to ops).
                        example: 0
                      target_locations:
                        type: array
                        description: The stores the requester named directly, resolved
                          to id + name and ordered by name. Empty when the request
                          names an audience or every location instead of a direct
                          list. Tenant-scoped — a store id from another business is
                          never surfaced here.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 42
                            name:
                              type: string
                              example: Store 042 - Downtown
                      requested_by:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 55
                          name:
                            type: string
                            example: Anthony Rivera
                      requested_at:
                        type: string
                        format: date-time
                        nullable: true
                      decided_by:
                        type: object
                        nullable: true
                        description: Who decided it — present once approved or declined.
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Dana Okafor
                      decided_at:
                        type: string
                        format: date-time
                        nullable: true
                      decline_reason:
                        type: string
                        nullable: true
                        description: The reason that travels back to the requester
                          — present only on a declined request.
                      under_engine_review:
                        type: boolean
                        description: The request is sitting in a configured approval
                          workflow (decided in the Approvals surface, not the queue's
                          buttons).
                        example: false
                      campaign:
                        type: object
                        nullable: true
                        description: The draft campaign the approval minted — present
                          only once approved.
                        properties:
                          id:
                            type: integer
                            example: 88
                          name:
                            type: string
                            example: Re-check cold chain after firmware fix
                          status:
                            type: string
                            example: draft
                      attachments:
                        type: array
                        description: The requester's files (expiring download URLs).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 91
                            filename:
                              type: string
                              example: planogram.pdf
                            byte_size:
                              type: integer
                              example: 220100
                            content_type:
                              type: string
                              example: application/pdf
                            url:
                              type: string
                              example: https://.../rails/active_storage/...
                  viewer:
                    type: object
                    description: The caller's lens on this intake request and the
                      actions they may take.
                    properties:
                      is_requester:
                        type: boolean
                        description: The caller submitted this request.
                        example: false
                      is_gatekeeper:
                        type: boolean
                        description: The caller is in the campaign-author tier that
                          works the queue.
                        example: true
                      can_approve:
                        type: boolean
                        description: Show the Approve control — the caller is a gatekeeper,
                          the request is still pending, and no configured approval
                          workflow owns the decision.
                        example: true
                      can_decline:
                        type: boolean
                        description: Show the Decline control — same condition as
                          can_approve.
                        example: true
                      can_withdraw:
                        type: boolean
                        description: Show the Withdraw control — the caller is the
                          submitter and the request is still pending.
                        example: false
              example:
                request:
                  id: 412
                  title: Re-check cold chain after firmware fix
                  status: withdrawn
                  decided_by:
                    id: 88
                    name: Priya Shah
                  decided_at: '2026-09-24T10:15:02Z'
                  decline_reason:
                  under_engine_review: false
                  campaign:
                viewer:
                  is_requester: true
                  is_gatekeeper: false
                  can_approve: false
                  can_decline: false
                  can_withdraw: false
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The tenant has used its monthly API allowance (`api_credits_exhausted`).
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller is not this request's
            submitter (`forbidden`).
          content:
            application/json:
              schema:
                type: object
                description: The standard error envelope for a refused intake-request
                  decision.
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: "`reason_required`, `already_decided`, `engine_owned`,
                          `invalid`, or (withdraw only) `forbidden`."
                        example: reason_required
                      message:
                        type: string
                        example: Say why — it's the only thing the person who asked
                          for this will see.
              example:
                error:
                  code: forbidden
                  message: You can only withdraw your own requests.
        '404':
          description: No such request in this business (`not_found`).
        '409':
          description: "`already_decided` — the request is no longer pending (approved,
            declined, already withdrawn, or a concurrent decision won the race)."
          content:
            application/json:
              schema:
                type: object
                description: The standard error envelope for a refused intake-request
                  decision.
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: "`reason_required`, `already_decided`, `engine_owned`,
                          `invalid`, or (withdraw only) `forbidden`."
                        example: reason_required
                      message:
                        type: string
                        example: Say why — it's the only thing the person who asked
                          for this will see.
              example:
                error:
                  code: already_decided
                  message: That request has already been decided.
  "/frontline_execution/items/{id}/claim":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Claim (take) an unclaimed pool item
      description: |
        Binds the caller to an unclaimed obligation and takes it out of the claim
        pool: the item moves to `claimed`, its `current_assignee` becomes the
        caller, `claimed_at` is stamped, a `self_claim` event is written to the
        item's history, and the fulfillment the campaign describes (a Task, or an
        Inspection for an inspection campaign) is minted into the worker's list.

        Only a caller who MAY claim the item reaches the transition. The item must
        be open and unassigned, its campaign must be active, the caller must be
        mapped to the item's location, and — when the campaign restricts the work
        to a role — the caller must hold that role. A caller who fails any of
        these is refused `403` before anything is written ("This work isn't
        available for you to claim.").

        The claim is contention-safe: if another worker claims the same item in
        the same instant, the loser is refused `422 already_assigned` ("Someone
        else just claimed this work.") rather than silently displacing the winner.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (pool obligation) to claim.
      responses:
        '200':
          description: Claimed. Returns the updated item (now `claimed`, assigned
            to the caller).
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ClaimedItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: 'App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller may not claim this
            item (`forbidden` — "This work isn''t available for you to claim.": already
            assigned, campaign not active, wrong location, or missing the required
            role).'
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: The claim was lost to a concurrent claimer (`already_assigned`),
            the work is no longer open (`not_claimable`), or the worker is at their
            critical-work cap.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClaimError"
  "/frontline_execution/items/{id}/complete":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Save and mark an obligation done (with photo / signature / note proof)
      description: |
        Marks the work done, capturing any submitted proof first.

        **Proof.** When the campaign demands a photo (`require_photo`) or a
        signature (`require_signature`), the matching proof must be present on the
        record before the work can finish — otherwise the completion is refused
        with `proof_required` and nothing changes. Submit photos as `photos[]`
        (multipart), a signature as a `signature_data` data URL, and an optional
        `completion_note`. Proof capture is **partial**: a photo the server refuses
        (not an image, over 10 MB, or past the 10-photo cap) is reported in
        `warnings` rather than discarding the rest of the submission — the valid
        photos, the signature and the note are already saved.

        **Outcome.** Work whose campaign does not require review moves straight to
        `done`. Work on a `requires_review` campaign moves to `submitted` and waits
        for a reviewer (see the accept/reject endpoints); the response `item.status`
        says which happened.

        **Refused (`422`).** `proof_required` (a required photo/signature is still
        missing), `already_done` / `missed` / `in_review` (the work is already in a
        terminal or in-review state), or a completion-policy code the shared gate
        raises (`off_shift`, `off_site`, `location_required`, `not_assigned` are
        returned as `403`; other policy codes as `422`).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (the obligation) to mark done.
      requestBody:
        required: false
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                photos:
                  type: array
                  description: Photo proof (JPG, PNG, GIF, WEBP or HEIC; up to 10
                    files, each ≤ 10 MB). Files past those limits are reported in
                    `warnings`, not fatal.
                  items:
                    type: string
                    format: binary
                signature_data:
                  type: string
                  description: A signature as a data URL (`data:image/png;base64,…`
                    or `data:image/jpeg;base64,…`), e.g. from a canvas signature pad.
                completion_note:
                  type: string
                  description: Optional note stored on the work's completion-notes
                    trail (≤ 500 chars).
                  example: Endcap reset and faced; dated the stock.
                latitude:
                  type: string
                  description: Optional GPS latitude, for a geofenced campaign's site
                    check.
                longitude:
                  type: string
                  description: Optional GPS longitude, for a geofenced campaign's
                    site check.
          application/json:
            schema:
              type: object
              properties:
                signature_data:
                  type: string
                  description: A signature data URL (when no photo upload is needed).
                completion_note:
                  type: string
                  description: Optional note stored on the work's completion-notes
                    trail (≤ 500 chars).
                  example: Endcap reset and faced; dated the stock.
                latitude:
                  type: string
                longitude:
                  type: string
      responses:
        '200':
          description: The work was marked done (or submitted for review). Returns
            the updated item, plus `warnings` when part of the submitted proof was
            refused.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/CompleteItem"
                  warnings:
                    type: array
                    description: Present only when part of the proof was refused (e.g.
                      a non-image file). Human sentences, one per refused item.
                    items:
                      type: string
                    example:
                    - notes.txt isn't an image. Use a JPG, PNG, GIF, WEBP, or HEIC.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), the caller isn't the holder/admin
            (`forbidden` — "This work isn't assigned to you."), or a completion policy
            stop (`off_shift`, `off_site`, `location_required`, `not_assigned`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompleteError"
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be completed: `proof_required` (a required
            photo/signature is missing), `already_done`, `missed`, `in_review`, `campaign_closed`
            (the campaign was closed — this work is no longer needed), or another
            completion-policy refusal.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompleteError"
  "/frontline_execution/items/{id}/checklist/{checklist_item_id}":
    patch:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Mark a checklist step done (or un-tick it)
      description: |
        Ticks one checklist step on the obligation's minted checklist Task. Send
        `completed=false` to un-tick. Returns the freshly-read checklist (the same
        shape `GET /frontline_execution/items/{id}` renders under `checklist`), the
        updated obligation, whether **every** step is now done (`all_completed` — the
        cue to offer the obligation's Complete action), and the minted Task's status.

        Steps authored inline in this app carry no evidence requirements. A campaign
        pointed at a Tasks-app template can flag a step "notes required" or "photo
        required"; supply `notes` and/or a multipart `photo` when it does. A step
        that already carries notes/photos is accepted without re-supplying them.

        Ticking an already-ticked step (or un-ticking an open one) is a **success**
        no-op — the desired state already holds. When the tick lands but the parent
        Task cannot auto-complete (a missing completion requirement), the step is
        still ticked and the reason is returned under `warnings`.

        Refused (`422`): `no_checklist` (the work is not a checklist Task with steps
        — an inspection, a simple task, or an unclaimed obligation), `task_finished`
        (the Task is already completed/cancelled — reopen it first), `requires_notes`
        / `requires_photo` (the step demands evidence none was supplied for), or
        `save_failed` (the row would not save). `404` `not_found` for an unknown step.
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) whose minted work holds the step.
        schema:
          type: integer
      - name: checklist_item_id
        in: path
        required: true
        description: The checklist step (TaskChecklistItem) to tick.
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                completed:
                  type: boolean
                  default: true
                  description: Tick the step (default) or, when false, un-tick it.
                  example: true
                notes:
                  type: string
                  nullable: true
                  description: Completion notes — required only for a step flagged
                    "notes required".
                  example: Cooler at 3°C, logged on the sheet.
          multipart/form-data:
            schema:
              type: object
              properties:
                completed:
                  type: boolean
                  default: true
                notes:
                  type: string
                  nullable: true
                photo:
                  type: string
                  format: binary
                  description: A single image (PNG/JPG/GIF/WebP/HEIC/HEIF/AVIF, ≤10MB)
                    — required only for a step flagged "photo required".
      responses:
        '200':
          description: The step was updated. Returns the refreshed checklist and obligation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineChecklistItemResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller is not the holder of
            this work and not an admin (`forbidden`).
        '404':
          description: No such obligation in this business, or no such step on its
            work (`not_found`).
        '422':
          description: 'The tick was refused: `no_checklist`, `task_finished`, `requires_notes`,
            `requires_photo`, `photo_failed` or `save_failed`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineChecklistItemError"
  "/frontline_execution/items/{id}/accept":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Accept (approve) a submitted item
      description: |
        Approves the submission: the work moves to `done`, the caller is stamped
        as the reviewer, and the optional `note` is stored as the review note.

        Refused (`422`) when the work is not awaiting review (`not_submitted` —
        including losing a race to another reviewer), or when the caller submitted
        the work themselves and separation of duties bars self-review
        (`self_review`).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (submitted work) to accept.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Optional note stored with the approval.
                  example: Approved — matches the planogram.
      responses:
        '200':
          description: Accepted. Returns the updated item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ReviewDecisionItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), or the caller can't review work
            at this location (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be accepted: `not_submitted` (not awaiting
            review, or a concurrent reviewer decided first) or `self_review` (the
            reviewer did the work).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReviewDecisionError"
  "/frontline_execution/items/{id}/reject":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Reject (send back for rework) a submitted item
      description: |
        Sends the submission back: the work moves to `reopened` and the `note` is
        stored as the review note and sent to the worker. `notified` in the
        response says whether that notice went out — `false` when the worker
        could not be told (they left, or the app's door refuses them), so tell
        them directly or hand the redo to someone else. It is absent when a
        configured approval workflow decided the send-back, which reports none.

        The `note` (the reason) is **required** — it is the only thing the person
        who did the work sees, so a reason-free send-back leaves them nothing to
        fix. A blank or too-short reason is refused with `reason_required`, and the
        work stays in review.

        Also refused (`422`) when the work is not awaiting review (`not_submitted`),
        the caller submitted it themselves (`self_review`), or the campaign was
        closed (`campaign_closed` — accept the work or leave it; a closed
        campaign's work is not sent back for a redo).

        Delete-level (owner decision 2026-10-04): requires the `destructive:frontline_execution`
        scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (submitted work) to send back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note
              properties:
                note:
                  type: string
                  description: The reason for the send-back (required). It is shown
                    to the worker on their My Day card and in the notification.
                  example: Reshoot the left bay — it's out of focus.
      responses:
        '200':
          description: Sent back for rework. Returns the updated item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ReviewDecisionItem"
                  notified:
                    type: boolean
                    description: Whether the worker's notice went out. Absent for
                      a send-back a configured approval workflow decided.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `destructive:frontline_execution` (`insufficient_permissions`),
            the My Day surface is off (`surface_disabled`), or the caller can't review
            work at this location (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be rejected: `reason_required` (no/blank reason),
            `not_submitted` (not awaiting review, or a concurrent reviewer decided
            first), `self_review` (the reviewer did the work) or `campaign_closed`
            (the campaign was closed).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReviewDecisionError"
  "/frontline_execution/items/{id}/block":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Flag an obligation as blocked ("I can't do this")
      description: |
        Records that the caller cannot do this work, with a structured `reason`
        and an optional free-text `note`, and notifies the location's accountable
        manager. Returns the item with its `blocked` state so a client can confirm
        the flag took.

        The `blocked_reason` is **required** and must be one of the allowed
        causes — a flag with no cause tells the manager nothing. A blank or
        unknown reason is refused with `invalid_reason`.

        A byte-identical re-flag (same reason and note on already-blocked work) is
        absorbed as a no-op success — it does not re-notify. Changing the reason
        or the note is a legitimate correction and goes through, records, and
        re-notifies.

        Refused (`422`) when the work is already finished (`already_done`) or is
        waiting on a reviewer (`in_review`).
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) to flag.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - blocked_reason
              properties:
                blocked_reason:
                  type: string
                  description: What is blocking this work (required). One of the fixed
                    set of causes, so the manager and the analytics rollup can tell
                    them apart.
                  enum:
                  - no_stock
                  - missing_fixture_or_supplies
                  - equipment_down
                  - not_enough_time
                  - unclear_instructions
                  - other
                  example: no_stock
                blocked_note:
                  type: string
                  description: Optional free-text detail for the manager (e.g. "Truck
                    is late — expected 2pm"). Capped at 500 characters (truncated,
                    not refused).
                  example: Fridge 2 alarm is sounding, panel reads ERR. Called facilities
                    at 9:20.
      responses:
        '200':
          description: Flagged (or the identical re-flag absorbed). Returns the item
            with its blocked state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/FrontlineBlockedItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or a plain member may not flag this
            work (`forbidden` — not the holder, and not on the roster for a must-read).
        '404':
          description: No such obligation in this business (`not_found`).
        '422':
          description: 'The flag was refused: `invalid_reason` (missing/blank/unknown
            reason), `not_assigned` (a non-holder — even an admin — flagging assigned
            work, or a non-roster caller flagging a must-read), `already_done` (finished
            work), `in_review` (waiting on a reviewer) or `campaign_closed` (the campaign
            was closed — this work is no longer needed).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineBlockError"
  "/frontline_execution/campaigns/{campaign_id}/questions":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Read a campaign's Q&A thread
      description: |
        Returns the campaign's questions, each with its replies, newest questions
        first (re-ordered to read top-to-bottom). The thread is the most recent
        slice; `meta.truncated` is true when there are more questions than the
        slice, and `question_count` is the true total (top-level questions) so a
        client can render the "N asked" counter.

        Readable by the author tier or anyone who holds the campaign's work,
        regardless of the campaign's status — a closed campaign's answers stay
        legible to every store that worked it.
      parameters:
      - name: campaign_id
        in: path
        required: true
        description: The campaign whose Q&A thread to read.
        schema:
          type: integer
      responses:
        '200':
          description: The Q&A thread.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineQuestionThread"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a read scope (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller may not see this campaign's
            questions (`forbidden` — not the author tier and not a holder).
        '404':
          description: No such campaign in this business (`not_found`).
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Ask a question on a campaign (or reply to one)
      description: |
        Posts a new question, or — with `parent_comment_id` — a reply aimed at an
        existing question, and notifies HQ (the campaign's author and everyone
        already in the thread, capped and deduped). Returns the created comment,
        how many were `notified`, a human `message` to flash, and the updated
        `question_count`.

        The `body` is **required** and capped at 2000 characters. A blank body is
        refused with `blank`; an over-length body with `too_long`; a
        `parent_comment_id` that does not resolve to one of this campaign's own
        top-level questions with `invalid_parent` (404). A closed campaign refuses
        a new question with `forbidden`.
      parameters:
      - name: campaign_id
        in: path
        required: true
        description: The campaign to ask on.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: The question (or reply) text. May contain @mentions.
                  example: Do the small-format stores skip the endcap, or is it required
                    everywhere?
                parent_comment_id:
                  type: integer
                  nullable: true
                  description: Omit to ask a NEW question. Set to a top-level question's
                    id on this campaign to REPLY to it (replies go one level deep).
                  example: 512
      responses:
        '200':
          description: Posted. Returns the created comment, the notified count and
            the new question total.
          content:
            application/json:
              schema:
                type: object
                properties:
                  question:
                    "$ref": "#/components/schemas/FrontlineQuestion"
                  notified:
                    type: integer
                    description: How many people were actually notified (0 when there
                      is no one but the poster).
                    example: 1
                  message:
                    type: string
                    description: A human notice describing what happened.
                    example: Question posted — HQ is notified.
                  question_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the Campaigns
            surface is off (`surface_disabled`), or the caller may not ask on this
            campaign (`forbidden` — not the author tier and not a holder, or the campaign
            is closed).
        '404':
          description: No such campaign in this business (`not_found`), or a `parent_comment_id`
            that does not resolve to one of this campaign's top-level questions (`invalid_parent`).
        '422':
          description: 'The body was refused: `blank` (empty) or `too_long` (over
            2000 characters).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineQuestionError"
  "/frontline_execution/items/{id}/release":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Release (give back) an assigned item
      description: |
        Detaches the holder from the work and returns it to the pool: the item
        moves to `open`, its `current_assignee` is cleared, and a `release` event
        is written to the item's history naming the person who held it (so "who
        held it" reads the same in the history even when an admin does the
        releasing). Any open "this shouldn't be mine" challenge on the item is
        withdrawn — releasing answers it. The obligation, its due date and its
        location are unchanged.

        Only the HOLDER of the item, or an admin, may release it. A caller who is
        neither is refused `403` before anything is written.

        Refused (`422`) when the work is already finished (`already_done`), is
        waiting on a reviewer (`in_review`), or is not currently assigned to
        anyone (`not_assigned` — there is nothing to release).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign item (assigned work) to release.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional note stored on the release event (why the
                    work was handed back). Unlike a manager's return-to-pool, the
                    holder's own release does not require a reason.
                  example: My shift is ending — someone on the next shift can pick
                    this up.
      responses:
        '200':
          description: Released. Returns the updated item (now `open`, no assignee).
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/ReleasedItem"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller does not hold the work
            and is not an admin (`forbidden` — "This work isn't assigned to you.").
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: 'The work can''t be released: `already_done` (settled), `in_review`
            (waiting on a reviewer), `not_assigned` (nobody holds it) or `campaign_closed`
            (the campaign was closed — this work is no longer needed).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReleaseError"
  "/frontline_execution/items/{id}/note":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Add a note to an obligation's work record
      description: |
        Appends a free-text `note` to the work's shared completion-notes trail,
        records a `note_added` activity, and notifies the location's reviewer.
        Returns the item plus the note that was recorded (author, body, timestamp)
        so a client can append it to the thread without a second round-trip.

        The `note` is **required**. A blank or non-scalar value is refused with
        `invalid_note` — nothing is written.

        Refused (`422`) when the obligation cannot take a note: `acknowledgement`
        (a must-read attestation mints no work record — confirm the read instead),
        `unsupported_work` (an inspection, whose notes live beside its answers in
        Inspections), or `no_work` (the work record could not be minted yet — a
        transient state, retrying may help).
      parameters:
      - name: id
        in: path
        required: true
        description: The obligation (CampaignItem) to note.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note
              properties:
                note:
                  type: string
                  description: The note text (required). Capped at 500 characters
                    (truncated, not refused).
                  example: Endcap reset done, but two SKUs were out of stock — flagged
                    to the DM.
      responses:
        '200':
          description: The note was recorded. Returns the item and the recorded note.
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/FrontlineNoteItem"
                  note:
                    "$ref": "#/components/schemas/FrontlineRecordedNote"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks a write scope (`insufficient_permissions`), the My Day
            surface is off (`surface_disabled`), or the caller is not the holder of
            this work and not an admin (`forbidden`).
        '404':
          description: No such obligation in this business (`not_found`).
        '422':
          description: 'The note was refused: `invalid_note` (missing / blank / non-scalar),
            `acknowledgement` (a must-read obligation with no work record), `unsupported_work`
            (an inspection) or `no_work` (work not mintable yet).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineNoteError"
  "/frontline_execution/items/{id}/nudge":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Nudge the holder of one obligation (a manager reminder)
      description: |
        Sends a reminder to whoever still owes this obligation — its current
        holder, or the location's accountable manager when it is unclaimed.

        The response always names the `recipient` and reports whether the reminder
        was actually delivered:
          * `nudged: true` — a fresh reminder was sent (in-app + push).
          * `nudged: false`, `throttled: true` — nothing new was sent: the
            recipient was already reminded within the last 24 hours, or could not
            be reached (a delivery-preference block, or the app is not accessible
            to them). The same honest hedge the web Coverage notice makes.

        `note` is optional free text that replaces the default reminder body.

        Refused (`422`, `no_recipient`) when nobody holds the work and no manager
        covers its location — there is nobody to nudge.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The obligation (Execution::CampaignItem) whose holder to nudge.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Optional message that replaces the default reminder
                    body ("You still have outstanding work at <store>.").
                  example: The truck is here — please finish the reset before close.
      responses:
        '200':
          description: The nudge was processed. `nudged`/`throttled` say whether a
            reminder was actually delivered; `recipient` names who it addressed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemNudgeResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the Coverage
            surface is off (`surface_disabled`), the caller is not in the campaign-author
            tier (`forbidden`), or the item's store is outside the caller's location
            span (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: Nobody holds the work and no manager covers its location, so
            there is nobody to nudge (`no_recipient`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemNudgeError"
  "/frontline_execution/items/{id}/reassign":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Reassign one obligation to a different person (a manager hand-off)
      description: |
        Hands this obligation to a different person. The work, its deadline and its
        coverage number stay the same — only the holder changes.

        The target (`user_id`) must be an active member of the business who is
        already assigned to the item's store. A manager override is deliberately NOT
        held to the campaign's expected-role match (covering with whoever is on shift
        is the manager's call), but the target must at least work at that location —
        the same fence the claim pool enforces.

        A reason is REQUIRED — a hand-off is an override of the resolver's pick, and
        the reason lands on the work's history and is the one thing the previous
        holder reads. Send `reason_code` (preferred): with a code, `reason` becomes
        an optional note, except for `other`, which needs one. A client that sends
        only `reason` (at least 3 characters) still works, but its hand-off carries
        no code and therefore informs no Muscle Memory lesson.

        An unclaimed pool item can be reassigned too (the web's "Assign someone"):
        it simply becomes claimed by the named person.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The obligation (Execution::CampaignItem) to reassign.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                  description: The person to hand the work to — an active member of
                    the business assigned to the item's store.
                  example: 4821
                reason_code:
                  type: string
                  enum:
                  - not_scheduled
                  - on_leave
                  - skills
                  - workload
                  - other
                  description: 'Why the work is being handed off, as a choice: not
                    scheduled or off shift, on leave or out sick, needs different
                    skills or training, already has too much on, or something else.
                    This is what Muscle Memory counts; the note is never interpreted.'
                  example: on_leave
                reason:
                  type: string
                  minLength: 3
                  description: A note for the work's assignment history, shown to
                    the previous holder. Required when `reason_code` is `other` or
                    absent.
                  example: Priya went home sick — please finish the reset.
      responses:
        '200':
          description: The obligation was reassigned. The body carries the reloaded
            item, now held by the new person.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemReassignResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `write:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), the caller is not in the reviewer
            tier, or the item's store is outside the caller's location span (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
        '422':
          description: No `user_id` given (`invalid_request`); the target doesn't
            work at the item's store (`not_at_location`); no usable reason — an unknown
            `reason_code`, `other` with no note, or neither a code nor a note of 3+
            characters (`reason_required`); the target already holds the work (`already_assigned`);
            the work is finished (`already_done`) or waiting on a reviewer (`in_review`);
            the campaign was closed (`campaign_closed` — this work is no longer needed);
            or the target has an approved absence over the work's window (a leave
            refusal).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ItemReassignError"
  "/frontline_execution/items/{id}/reassign_candidates":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The store roster for reassigning one obligation (with search + pagination)
      description: |
        Returns the people the obligation can be reassigned to — the active members
        assigned to the item's store, ranked most-available first (on shift, then
        least loaded, then name). Each row carries the availability signals the web
        reassign dropdown shows, and flags the current holder.

        `q` narrows the roster by name (first name, last name, or the two joined).
        `page` and `limit` page the ranked result. The base roster is capped at 100
        people before ranking (the web renders every one as an option); when a store
        has more, `meta.roster_capped` is true and `q` is how to reach the rest.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The obligation (Execution::CampaignItem) being reassigned.
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Narrow the roster by name (case-insensitive substring).
        example: priya
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
        description: Page number (default 1).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page (1–100, default 25).
      responses:
        '200':
          description: The ranked store roster for this obligation.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReassignCandidatesResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `read:execution` (`insufficient_permissions`), the My
            Day surface is off (`surface_disabled`), the caller is not in the reviewer
            tier, or the item's store is outside the caller's location span (`forbidden`).
        '404':
          description: No such item in this business (`not_found`).
  "/meal_compliance/dashboard":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: The Meal Compliance dashboard (tiles, withdrawals, recent activity,
        behavior changes)
      description: |
        Every figure is relationship-scoped exactly as on the web Dashboard:
        HR admins, business admins and Meal Compliance app admins see the whole
        organization (`scope: all`); a line manager sees their direct reports and
        themself (`scope: team`); anyone else sees their own records (`scope: self`).
        A token holding only `read:own_meal_compliance` is answered as `self`
        whatever the caller's role.

        On a `self` dashboard, sample waivers and sample break records are hidden
        from a real person, and a draft a manager discarded is left out — the
        same rules as My Breaks.

        The response costs a fixed number of queries whatever the number of
        waivers or people.
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 10
          default: 4
        description: How many rows of Recent activity to return (clamped 1–10). The
          default, 4, is the number the mobile Dashboard lists; 10 is the web card's
          length.
      - name: locale
        in: query
        required: false
        schema:
          type: string
          example: es
        description: The language for the words this response writes (tile labels
          and captions, status labels, row descriptions). Omitted, the Accept-Language
          header decides, then the caller's own language.
      responses:
        '200':
          description: The dashboard for the caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceDashboard"
              example:
                scope: all
                tiles:
                - key: active_waivers
                  label: Active waivers
                  value: 5
                  caption: 71% of waiving employees covered
                  icon: file-signature
                  tone: success
                  coverage:
                    percentage: 71
                    waiving_employees: 7
                    covered: 5
                    not_covered: 2
                    not_covered_label: See 2 not covered
                - key: pending_signatures
                  label: Pending signatures
                  value: 3
                  caption: 2 awaiting employee · 1 waiting on a manager
                  icon: clock
                  tone: warning
                  awaiting_employee: 2
                  waiting_on_manager: 1
                - key: behavior_alerts
                  label: Behavior alerts
                  value: 3
                  caption: Last 7 days
                  icon: bell
                  tone: info
                  window_days: 7
                - key: invalidated
                  label: Invalidated
                  value: 1
                  caption: Ended or declined, not in force
                  icon: triangle-exclamation
                  tone: danger
                withdrawals:
                  total: 1
                  window_days: 7
                  items:
                  - waiver_id: 412
                    kind: withdrawn
                    description: Withdrew their meal-break waiver
                    ended_at: '2026-10-21T09:12:44Z'
                    employee:
                      id: 5120
                      name: Omar Haddad
                      initials: OH
                      avatar_url: https://ui-avatars.com/api/?name=Omar%20Haddad
                recent_activity:
                  limit: 4
                  items:
                  - waiver_id: 418
                    status: pending_signature
                    status_label: Pending Signature
                    tone: warning
                    location:
                      id: 31
                      name: Downtown Store
                    updated_at: '2026-10-21T08:40:02Z'
                    employee:
                      id: 5133
                      name: Grace Liu
                      initials: GL
                      avatar_url: https://ui-avatars.com/api/?name=Grace%20Liu
                behavior_changes:
                  total: 1
                  window_days: 7
                  items:
                  - id: 902
                    previous_pattern: taking
                    previous_pattern_label: Taking
                    detected_pattern: waiving
                    detected_pattern_label: Waiving
                    summary: Changed from Taking to Waiving
                    detected_at: '2026-10-19T06:00:00Z'
                    employee:
                      id: 5133
                      name: Grace Liu
                      initials: GL
                      avatar_url: https://ui-avatars.com/api/?name=Grace%20Liu
                permissions:
                  can_create_waiver: true
                  can_view_waivers: true
                  can_view_gaps: true
                  gaps_web_url: https://acme.workforce.mangoapps.com/apps/meal-compliance/waivers/gaps
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks `read:meal_compliance` (`insufficient_permissions`),
            the app is not on for the organization (`app_disabled`), or the caller
            may not open it (`app_access_denied`).
        '404':
          description: The Meal Compliance app is not installed (`app_not_found`).
  "/meal_compliance/waivers":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: The Waivers register (status filters with counts, search, location,
        sort, paginated)
      description: |
        Every row is relationship-scoped exactly as on the web register:
        HR admins, business admins and Meal Compliance app admins see the whole
        organization (`scope: all`); a line manager sees their direct reports and
        themself (`scope: team`); anyone else sees their own waivers
        (`scope: self`). A token holding only `read:own_meal_compliance` is
        answered as `self` whatever the caller's role. On a `self` register a
        draft a manager discarded is left out, and sample waivers are hidden
        from a real person — the same rules as the Dashboard and My Breaks.

        **Status filters.** `filters` always carries All, Active, Pending
        Signature, Invalidated, Expired and Draft with their counts, and Not
        Yet In Effect while it holds a waiver or is selected — the web
        register's chips. Statuses are read the way each row's badge reads:
        `active` is in force today, `expired` includes a waiver whose term ended
        before the 4-hourly sweep reached it, and `not_yet_in_effect` is a
        signed waiver whose term starts later. The counts honour `search` and
        `location_id` but not `status`, so they always add up to All and a chip
        never promises rows the list will not show.

        The response costs a fixed number of queries whatever the number of
        waivers or people.
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - all
          - active
          - pending_signature
          - invalidated
          - expired
          - draft
          - not_yet_in_effect
          default: all
        description: The status chip. An unrecognised value lists every waiver and
          sets `meta.filter_ignored`.
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Matches the employee's first, last, preferred or full name —
          and their email only where the People directory shows this caller email.
      - name: location_id
        in: query
        required: false
        schema:
          type: integer
        description: One of `locations[].id`. A location of another organization matches
          nothing.
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - valid_until
          - employee
        description: Omitted, the most recently updated waivers come first. `valid_until`
          puts waivers with no expiry last.
      - name: dir
        in: query
        required: false
        schema:
          type: string
          enum:
          - asc
          - desc
          default: asc
        description: The sort direction; ignored without `sort`.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Clamped to 1–100. 25 is the web register's page.
      - name: locale
        in: query
        required: false
        schema:
          type: string
          example: es
        description: The language for the words this response writes (chip, status,
          behavior and tag labels). Omitted, the Accept-Language header decides, then
          the caller's own language.
      responses:
        '200':
          description: One page of the register for the caller. The `X-Total-Count`,
            `X-Total-Pages`, `X-Current-Page` and `X-Per-Page` headers repeat `meta`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaivers"
              example:
                scope: all
                status: all
                search:
                location_id:
                sort:
                dir:
                filters:
                - key: all
                  label: All
                  count: 12
                  selected: true
                - key: active
                  label: Active
                  count: 4
                  selected: false
                - key: pending_signature
                  label: Pending Signature
                  count: 3
                  selected: false
                - key: invalidated
                  label: Invalidated
                  count: 1
                  selected: false
                - key: expired
                  label: Expired
                  count: 2
                  selected: false
                - key: draft
                  label: Draft
                  count: 2
                  selected: false
                locations:
                - id: 41
                  name: North Warehouse
                - id: 42
                  name: Seattle Distribution Center
                items:
                - id: 904
                  status: active
                  status_label: Active
                  tone: success
                  employee:
                    id: 5133
                    name: Omar Haddad
                    email: omar.haddad@acme.com
                    initials: OH
                    avatar_url: https://ui-avatars.com/api/?name=Omar%20Haddad
                    active_member: true
                  behavior:
                    state: waiving
                    label: Waiving
                    icon: forward
                  elections_summary: '1st meal period: Waived · 2nd meal period: Waived'
                  jurisdiction_name: California
                  location:
                    id: 41
                    name: North Warehouse
                  valid_from: '2026-04-15'
                  valid_until:
                    date: '2026-10-25'
                    label:
                    days_until_expiry: 4
                    tag:
                      kind: expiring
                      label: Expires in 4 days
                      tone: warning
                  is_sample_data: false
                  created_at: '2026-04-10T16:02:11Z'
                  updated_at: '2026-10-18T09:14:00Z'
                meta:
                  total_count: 12
                  total_pages: 1
                  current_page: 1
                  per_page: 25
                  has_next_page: false
                  has_prev_page: false
                permissions:
                  can_create_waiver: true
                  can_export: true
                  export_web_url: https://acme.workforce.mangoapps.com/apps/meal-compliance/waivers/export.csv?status=active
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks `read:meal_compliance` (`insufficient_permissions`),
            the app is not on for the organization (`app_disabled`), or the caller
            may not open it (`app_access_denied`).
        '404':
          description: The Meal Compliance app is not installed (`app_not_found`).
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Create a waiver, as a draft
      description: |
        The web New Waiver form's save (MealCompliance::WaiverCreation — the
        form's own writer). The new waiver is a DRAFT: not in force, and the
        employee has seen nothing; the answer is its page — the same body as
        `GET /meal_compliance/waivers/{id}` — with the next step (Send for
        Signature, Activate) in its `actions`.

        Only a caller who may create waivers (HR, an admin, a Meal Compliance
        app admin or a line manager) with the wide `write:meal_compliance`
        scope reaches it; a line manager only for their own direct reports —
        anyone else named is a `user_id` field error, as on the web form. A
        waiver already on its way for the employee (a draft, one out for
        signature, one signed that has not started) holds the save with 409
        until `create_beside_in_progress` names it.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/MealComplianceWaiverCreateRequest"
            example:
              user_id: 5133
              behavior_state: waiving
              location_id: 41
              jurisdiction_id: 7
              valid_from: '2026-10-22'
              valid_until: '2027-04-20'
              notes: Signed at orientation.
      responses:
        '201':
          description: 'Saved. `message` names the next step; `tone` is `warning`
            when the per-period elections could not be recorded — `message` then says
            so too: the draft reflects no waived period and, having no edit route,
            should be discarded and created again.'
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    message:
                      type: string
                      example: Draft saved for Omar Haddad. It isn't in force until
                        it's signed — send it for signature or activate it.
                    tone:
                      type: string
                      enum:
                      - success
                      - warning
                - "$ref": "#/components/schemas/MealComplianceWaiverDetails"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller may not create waivers, or the token is read-only
            or own-tier (`forbidden`); or the token lacks the scope (`insufficient_permissions`),
            or the app is not open to the caller.
        '409':
          description: A waiver is already on its way for this employee (`in_progress_waiver`).
            `error.details.in_progress_waiver` names it and `error.details.create_override`
            says how to save beside it.
          content:
            application/json:
              example:
                error:
                  code: in_progress_waiver
                  message: 'Omar Haddad already has a waiver on its way: waiver #918
                    (draft). …'
                  details:
                    in_progress_waiver:
                      id: 918
                      status: draft
                      status_label: Draft
                    create_override:
                      param: create_beside_in_progress
                      value: 918
                      label: Create another waiver anyway
        '422':
          description: 'Field errors (`errors[]` of `{ field, message, code }`): no
            employee, someone outside a line manager''s reach, an employee of another
            organization or no longer active in it, a missing or unrecognised Behavior
            State where it is chosen, `elections` that is not an object, a Location
            or Jurisdiction that is not active here, a Valid Until before today or
            before Valid From, Notes over 2,000 characters.'
          content:
            application/json:
              example:
                errors:
                - field: user_id
                  message: must be one of your direct reports. Ask an HR or app administrator
                    to create waivers for other employees.
                  code: validation_error
  "/meal_compliance/waivers/search":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Search waivers by employee name or email
      description: |
        The Search screen of the Waivers tab. Matches `q` against the
        employee's first, last, preferred and full name, and their email only
        where the People directory shows this caller email
        (`searchable_by` says which) — the web register's own search box, over
        the same relationship scope as `GET /meal_compliance/waivers`
        (`scope: all | team | self`; a `read:own_meal_compliance` token is
        always `self`). A waiver never matches on its location, jurisdiction
        or status, and a `status`, `location_id` or `sort` parameter is
        ignored: the results are every status, most recently updated first.

        A blank `q` is the idle screen and reads no waiver. The response
        carries the words the screen draws in the caller's language: the
        field's placeholder, the results line ("3 results") and the idle or
        no-results empty state.

        The response costs a fixed number of queries whatever the number of
        matches.
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
          example: Marcus
        description: The employee's name (any part, case-insensitive) or email. Trimmed;
          blank, or a list-shaped value, is the idle screen.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Clamped to 1–100.
      - name: locale
        in: query
        required: false
        schema:
          type: string
          example: es
        description: The language for the words this response writes. Omitted, the
          Accept-Language header decides, then the caller's own language.
      responses:
        '200':
          description: One page of matches. With a query, the `X-Total-Count`, `X-Total-Pages`,
            `X-Current-Page` and `X-Per-Page` headers repeat `meta`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverSearch"
              examples:
                results:
                  summary: Matches for "Marcus"
                  value:
                    scope: all
                    query: Marcus
                    searchable_by:
                    - name
                    - email
                    placeholder: Search by employee name or email
                    results_label: 1 result
                    empty_state:
                    items:
                    - id: 911
                      status: pending_signature
                      status_label: Pending Signature
                      tone: warning
                      subtitle: Meal Waiver (California) · North Warehouse
                      employee:
                        id: 5140
                        name: Marcus Lee
                        email: marcus.lee@acme.com
                        initials: ML
                        avatar_url: https://ui-avatars.com/api/?name=Marcus%20Lee
                        active_member: true
                      behavior:
                        state: waiving
                        label: Waiving
                        icon: forward
                      elections_summary: '1st meal period: Waived · 2nd meal period:
                        Taking'
                      jurisdiction_name: California
                      location:
                        id: 41
                        name: North Warehouse
                      valid_from: '2026-10-01'
                      valid_until:
                        date: '2027-03-30'
                        label:
                        days_until_expiry: 160
                        tag:
                      is_sample_data: false
                      created_at: '2026-09-28T16:02:11Z'
                      updated_at: '2026-10-19T09:14:00Z'
                    meta:
                      total_count: 1
                      total_pages: 1
                      current_page: 1
                      per_page: 25
                      has_next_page: false
                      has_prev_page: false
                idle:
                  summary: Nothing typed yet
                  value:
                    scope: team
                    query:
                    searchable_by:
                    - name
                    placeholder: Search by employee name
                    results_label:
                    empty_state:
                      kind: idle
                      title: Search waivers
                      body: Find a waiver by the employee's name.
                    items: []
                    meta:
                      total_count: 0
                      total_pages: 0
                      current_page: 1
                      per_page: 25
                      has_next_page: false
                      has_prev_page: false
                no_results:
                  summary: No match
                  value:
                    scope: all
                    query: Zelda
                    searchable_by:
                    - name
                    - email
                    placeholder: Search by employee name or email
                    results_label:
                    empty_state:
                      kind: no_results
                      title: No waivers for “Zelda”
                      body: Check the spelling, or search by the employee's email.
                    items: []
                    meta:
                      total_count: 0
                      total_pages: 0
                      current_page: 1
                      per_page: 25
                      has_next_page: false
                      has_prev_page: false
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks `read:meal_compliance` (`insufficient_permissions`),
            the app is not on for the organization (`app_disabled`), or the caller
            may not open it (`app_access_denied`).
        '404':
          description: The Meal Compliance app is not installed (`app_not_found`).
  "/weekly_reports/current":
    get:
      tags:
      - Weekly Reports
      security:
      - BearerAuth: []
      summary: This period's report
      description: |
        The caller's report for the current period — or, before anything is
        written, `report: null` with the period and whether this person is
        asked to write one at all (`asked`: a report template applies to them).
        Requires `read:weekly_reports`.
      responses:
        '200':
          description: The current report, the period and whether one is asked for.
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                - period
                - asked
                properties:
                  report:
                    allOf:
                    - "$ref": "#/components/schemas/WeeklyReport"
                    nullable: true
                    description: Null until the caller starts this period's report.
                  period:
                    type: object
                    required:
                    - start
                    - end
                    - due_at
                    properties:
                      start:
                        type: string
                        format: date
                        example: '2026-10-26'
                      end:
                        type: string
                        format: date
                        example: '2026-11-01'
                      due_at:
                        type: string
                        format: date-time
                        example: '2026-10-30T17:00:00-07:00'
                  asked:
                    type: boolean
                    description: Whether a report template applies to the caller —
                      false means nobody asks them for one.
                    example: true
        '403':
          description: "`app_disabled`, `app_forbidden`, or `insufficient_permissions`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_reports/current/items":
    post:
      tags:
      - Weekly Reports
      security:
      - BearerAuth: []
      summary: Add a line to this period's report
      description: |
        Adds one line, starting the report when there is none. `win: true`
        puts it under Wins (the template's first section if it renamed Wins);
        otherwise `section_key`, and with neither, under Done. A line in a plan
        section starts as a plan (`plan_outcome: open`) the next period asks
        about; one under "Could use a hand with" (`help`) is an open ask
        (`help_status: open`). A grouped section takes its lines in the form,
        not here. Adding to a shared report marks it edited. Requires
        `write:weekly_reports`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              properties:
                text:
                  type: string
                  description: The line, in the writer's own words (trimmed; capped
                    at the item limit).
                  example: Closed the Q4 vendor review
                section_key:
                  type: string
                  description: One of the report's `sections[].key`. Ignored when
                    `win` is true.
                  example: done
                win:
                  type: boolean
                  default: false
                  description: Put the line under Wins.
      responses:
        '201':
          description: The line was added.
          content:
            application/json:
              schema:
                type: object
                required:
                - item
                - report
                properties:
                  item:
                    "$ref": "#/components/schemas/WeeklyReportItem"
                  report:
                    "$ref": "#/components/schemas/WeeklyReport"
        '403':
          description: "`app_disabled`, `app_forbidden`, `insufficient_permissions`,
            or the report is not the caller's own / a service account acting as them"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`invalid` — the week is skipped, the section does not exist
            or is grouped, or `text` is blank. `error.message` says which."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_reports/current/skip":
    post:
      tags:
      - Weekly Reports
      security:
      - BearerAuth: []
      summary: Skip this period
      description: |
        Marks this period's report skipped (starting it first when there is
        none), with an optional `note` only the writer sees (stored, not sent
        back on the report). Only a draft can be skipped; any plan carried into
        it goes back to unanswered. Requires `write:weekly_reports`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Why, for the writer's own eyes.
                  example: On leave this week
      responses:
        '200':
          description: The report, now skipped.
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                properties:
                  report:
                    "$ref": "#/components/schemas/WeeklyReport"
        '403':
          description: "`app_disabled`, `app_forbidden`, `insufficient_permissions`,
            or a service account acting as the person"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`invalid` — the report is already shared or skipped."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_reports/asks/{item_id}/offers":
    parameters:
    - name: item_id
      in: path
      required: true
      description: The ask — a line in a shared report's "Could use a hand with" section.
      schema:
        type: integer
    post:
      tags:
      - Weekly Reports
      security:
      - BearerAuth: []
      summary: Offer to help with a teammate's ask
      description: |
        "I can help" on an ask in a shared report the caller may read. The
        writer is told once, on the first offer; a client opens the Chat
        message to `writer` itself. Offering again is a `200` with
        `new_offer: false`. Requires `write:weekly_reports`.
      responses:
        '201':
          description: A new offer was recorded and the writer told.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/WeeklyReportHelpOffer"
        '200':
          description: The caller had already offered; nothing new was written.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/WeeklyReportHelpOffer"
        '403':
          description: "`app_disabled`, `app_forbidden`, `insufficient_permissions`,
            `forbidden` (the caller cannot see the report), or a service account acting
            as the person"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — no ask the caller can see has that id."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`invalid` — not an ask for help, the caller's own ask, or
            already sorted."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Weekly Reports
      security:
      - BearerAuth: []
      summary: Take back an offer of help
      description: |
        Withdraws the caller's offer on the ask. An ask the caller offered on
        stays theirs to withdraw from even after the writer narrowed who reads
        the report. `withdrawn` is false when there was no offer. Requires
        `write:weekly_reports`.
      responses:
        '200':
          description: The ask after the withdrawal.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_id
                - help_status
                - withdrawn
                properties:
                  item_id:
                    type: integer
                    example: 812
                  help_status:
                    "$ref": "#/components/schemas/WeeklyReportHelpStatus"
                  withdrawn:
                    type: boolean
                    description: False when the caller had no offer on this ask.
                    example: true
        '403':
          description: "`app_disabled`, `app_forbidden`, `insufficient_permissions`,
            or a service account acting as the person"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — no ask the caller can see (or offered on) has
            that id."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/meal_compliance/waivers/new":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: The New Waiver form — choices, defaults, help, and the prefill for
        an employee or a renewal
      description: |
        Everything the + sheet draws: each field's label, placeholder and help,
        the Location and Jurisdiction choices, Behavior State (or, where the
        organization's break rules define more than one meal period, per-period
        elections that decide it), the default term (today to today + the
        organization's waiver length), the Valid Until rule with its "No
        expiration" wording, and the 2,000-character Notes limit — in the
        client's language. Behavior State opens with no value (`null`): it is
        a choice the creator makes, never a default.

        `user_id` opens it for an active member the caller may create a waiver
        for (ignored otherwise — `employee` is then null): `employee` is
        filled, with the Location and Jurisdiction the form suggests from their
        primary location (the web form leaves both to the creator), and
        `in_progress_waiver` names a waiver already on its way for them —
        saving another needs `create_override`. `from_waiver_id` opens it as a
        renewal of that waiver ("Start a new waiver for …"): its employee,
        location, jurisdiction, choice and elections, and — for one still in
        force — the next term, starting the day after it ends.
      parameters:
      - name: user_id
        in: query
        required: false
        schema:
          type: integer
      - name: from_waiver_id
        in: query
        required: false
        schema:
          type: integer
      - "$ref": "#/components/parameters/MealComplianceLocale"
      responses:
        '200':
          description: The form.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceNewWaiverForm"
              example:
                title: New Waiver
                renewal_of:
                employee:
                  id: 5133
                  name: Omar Haddad
                  email: omar.haddad@acme.com
                  initials: OH
                  avatar_url: https://ui-avatars.com/api/?name=OH
                  job_title: Line Cook
                  location:
                    id: 41
                    name: North Warehouse
                  jurisdiction:
                    id: 7
                    label: California (CA)
                    level: state
                  in_progress_waiver:
                in_progress_waiver:
                create_override:
                fields:
                  employee:
                    label: Employee
                    required: true
                    value: 5133
                    placeholder: Search for an employee...
                    scope_note:
                  location:
                    label: Location
                    required: false
                    value: 41
                    placeholder: Select a location (optional)...
                    options:
                    - id: 41
                      name: North Warehouse
                  behavior_state:
                    label: Behavior State
                    required: true
                    shown: true
                    value:
                    placeholder: Choose waiving or taking…
                    options:
                    - value: waiving
                      label: Waiving — skips meal breaks
                    - value: taking
                      label: Taking — takes meal breaks
                    note:
                  elections:
                    label: Meal period elections
                    shown: false
                    options: []
                    periods: []
                  jurisdiction:
                    label: Jurisdiction
                    required: false
                    value: 7
                    placeholder: Select jurisdiction...
                    options:
                    - id: 7
                      label: California (CA)
                      level: state
                    help: The country, state, or city jurisdiction whose labor law
                      this waiver is written under.
                    empty_note:
                  valid_from:
                    label: Valid From
                    required: false
                    value: '2026-10-22'
                  valid_until:
                    label: Valid Until
                    required: false
                    value: '2027-10-22'
                    min: '2026-10-22'
                    help: Must be today or later — …
                    no_expiry_label: No expiration
                  notes:
                    label: Notes
                    required: false
                    max_length: 2000
                    placeholder: Optional notes about this waiver...
                    help: The employee sees this — … Up to 2
                    000 characters.:
                submit_label: Create Waiver
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller may not create waivers, or the token is read-only
            or own-tier (`forbidden`); or the token lacks the scope (`insufficient_permissions`),
            or the app is not open to the caller.
  "/meal_compliance/waivers/employees":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: The New Waiver employee picker — exactly the people the caller may
        create a waiver for
      description: |
        The web form's typeahead, so the picker never offers someone the save
        then refuses: HR, admins and app admins search the whole directory
        (the platform's people search, on its deterministic order — no person
        repeats or goes missing across pages); a line manager gets their own
        active direct reports, never themself. Each person carries the
        Location and Jurisdiction the form suggests for them (their primary
        location here, and its state jurisdiction — else city, else country —
        among the organization's active ones; the web form leaves both to the
        creator) and the waiver already on its way for them. Email and job
        title only where the People directory shows them to the caller. A
        fixed number of queries per page.
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Name (and email, where the directory shows it). Blank lists the
          first page.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - "$ref": "#/components/parameters/MealComplianceLocale"
      responses:
        '200':
          description: One page of people (50).
          content:
            application/json:
              schema:
                type: object
                properties:
                  query:
                    type: string
                    nullable: true
                  scope_note:
                    type: string
                    nullable: true
                    description: 'Who the picker lists, for a line manager: "Only
                      your direct reports are listed. Ask an HR or app administrator
                      to create a waiver for anyone else." Null for HR and administrators,
                      who reach everyone.'
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MealComplianceWaiverEmployeeOption"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                        example: 50
                      has_next_page:
                        type: boolean
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller may not create waivers, or the token is read-only
            or own-tier (`forbidden`); or the token lacks the scope (`insufficient_permissions`),
            or the app is not open to the caller.
  "/meal_compliance/waivers/{id}":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: One waiver's details, with a permission flag per action the caller
        may take on it
      description: |
        **Who may open it.** The Waivers register's population: HR admins,
        business admins and Meal Compliance app admins any waiver of the
        organization; a line manager their direct reports' waivers and their
        own; anyone else their own. A token holding only
        `read:own_meal_compliance` reaches the caller's own waivers whatever
        their role. A draft a manager discarded, and a sample waiver in a real
        person's name, are hidden from that person here as on their list.

        **What they may do.** `permissions` carries one flag per action, true
        only when the caller may take it on the waiver as it stands — the same
        answer the web page draws its buttons from:

        | State | Actions offered to someone who manages the waiver |
        |---|---|
        | Draft | Send for Signature (where the organization offers e-signature and the employee can sign — see below), Activate, Discard draft |
        | Pending Signature, never sent | Send for Signature, Activate, Discard (never sent) — nobody was asked to sign it, so it is discarded, not declined |
        | Pending Signature, its request lapsed | Send for Signature, Activate, Employee declined it, Invalidate |
        | Pending Signature, out for signature | Employee declined it, Invalidate, Resend signing link / Remind to sign (a live link E-Signature would remind) |
        | Pending Signature, out for signature but the employee's link ran out unsigned | Activate, Employee declined it, Invalidate, Resend signing link / Remind to sign |
        | Active (in force or not yet in effect) | Employee withdrew it, Invalidate |
        | Expired, Invalidated (and an active waiver past its term) | Start a new waiver — while the employee is still a member and no other waiver of theirs is in force |

        "Someone who manages the waiver" is HR, an admin, an app admin, or the
        employee's line manager — and the token must hold the wide
        `write:meal_compliance`. A former employee's waiver is never sent or
        activated, and nobody activates their own waiver (another HR or app
        administrator puts it on file). An employee with an email is sent a
        signing link; one with no email is sent the waiver to sign in the app,
        after confirming it is them with a personal PIN, a password they use
        or a code texted to their mobile — Send is withheld (`no_email`) only
        when they have no email AND no way to confirm it is them in the app.
        The waiver's own employee (who does not manage it) gets
        Revoke my waiver / Withdraw before signing on a live or pending
        waiver, with `write:own_meal_compliance` or wider, and Review & Sign
        while their signing link is live. Download signed
        waiver is offered wherever E-Signature would let the caller open the
        signed copy. A read-only token gets every flag false.

        `actions` lists the permitted actions where the prototype draws them —
        `primary` (the hero's button), `pinned` (the bottom bar) or `menu` (the
        ⋯ menu) — each with its label and confirm sheet, and `notices` says why
        an action the page would offer is missing (a former employee, no email,
        Document Signing switched off).

        The response costs a fixed number of queries whatever the employee's
        history.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The waiver's id — `items[].id` from `GET /meal_compliance/waivers`.
      - name: locale
        in: query
        required: false
        schema:
          type: string
          example: es
        description: The language for the words this response writes (labels, confirm
          sheets, notices). Omitted, the Accept-Language header decides, then the
          caller's own language.
      responses:
        '200':
          description: The waiver page for the caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverDetails"
              examples:
                active:
                  summary: An active waiver, read by HR
                  value:
                    waiver:
                      id: 904
                      status: active
                      status_label: Active
                      tone: success
                      employee:
                        id: 5133
                        name: Omar Haddad
                        email: omar.haddad@acme.com
                        initials: OH
                        avatar_url: https://acme.workforce.mangoapps.com/rails/active_storage/representations/redirect/…/avatar.jpg
                        job_title: Line Cook
                        active_member: true
                      behavior:
                        state: waiving
                        label: Waiving
                        icon: forward
                      elections:
                      - period_ordinal: 1
                        label: 1st meal period
                        waived: true
                        choice_label: Waived
                      - period_ordinal: 2
                        label: 2nd meal period
                        waived: false
                        choice_label: Taking
                      elections_summary: '1st meal period: Waived · 2nd meal period:
                        Taking'
                      jurisdiction_name: Washington
                      location:
                        id: 41
                        name: North Warehouse
                      valid_from: '2026-04-01'
                      valid_from_tag:
                      valid_until:
                        date: '2026-10-30'
                        label:
                        days_until_expiry: 9
                        tag:
                          kind: expiring
                          label: Expires in 9 days
                          tone: warning
                        note:
                      ended:
                      activated_by:
                      notes:
                      is_sample_data: false
                      created_at: '2026-03-28T16:02:11Z'
                      updated_at: '2026-04-01T09:15:40Z'
                    esignature:
                      request_id: 3310
                      name: Meal Break Waiver – Omar Haddad
                      status: completed
                      status_label: Completed
                      sent_at: '2026-03-28T16:05:02Z'
                      signing_link:
                      signed_copy:
                        esignature_request_id: 3310
                        download_path: "/apps/esignature/requests/3310/download"
                    behavior_history:
                      limit: 20
                      items:
                      - id: 77
                        label: 'Sep 19 – Oct 3: skipped 13 of 14 days'
                        detected_pattern: waiving
                        previous_pattern: taking
                        window_start: '2026-09-19'
                        window_end: '2026-10-03'
                        detected_at: '2026-10-03T06:00:00Z'
                        days_analyzed: 14
                        days_waived: 13
                        days_taken: 1
                        action_label:
                    notices: []
                    permissions:
                      can_send_for_signature: false
                      can_activate: false
                      can_invalidate: true
                      can_record_employee_withdrawal: true
                      can_discard_draft: false
                      can_resend_signing_link: false
                      can_download_signed_waiver: true
                      can_start_new_waiver: true
                      can_revoke_own_waiver: false
                    actions:
                    - key: start_new_waiver
                      label: Start the next waiver for Omar Haddad
                      icon: plus
                      tone: primary
                      placement: primary
                      confirm:
                      prefill:
                        user_id: 5133
                        from_waiver_id: 904
                    - key: record_employee_withdrawal
                      label: Employee withdrew it
                      icon: rotate-left
                      tone: danger
                      placement: pinned
                      reason: employee_revoked
                      confirm:
                        title: Employee withdrew it?
                        message: Record that Omar Haddad asked to withdraw their waiver?
                        confirm_label: Confirm
                    - key: invalidate
                      label: Invalidate
                      icon: ban
                      tone: danger
                      placement: pinned
                      reason: manual
                      confirm:
                        title: Invalidate this waiver?
                        message: Invalidate this waiver on the employer's initiative?
                          Use this when the business is ending the waiver.
                        confirm_label: Confirm
                    - key: download_signed_waiver
                      label: Download signed waiver
                      icon: file-arrow-down
                      tone: primary
                      placement: menu
                      confirm:
                      esignature_request_id: 3310
                      download_path: "/apps/esignature/requests/3310/download"
                draft:
                  summary: A draft, read by the employee's line manager
                  value:
                    waiver:
                      id: 918
                      status: draft
                      status_label: Draft
                      tone: info
                      employee:
                        id: 5140
                        name: Priya Shah
                        email:
                        initials: PS
                        avatar_url: https://ui-avatars.com/api/?name=PS
                        job_title: Server
                        active_member: true
                      behavior:
                        state: taking
                        label: Taking
                        icon: utensils
                      elections: []
                      elections_summary:
                      jurisdiction_name: California
                      location:
                        id: 42
                        name: Seattle Distribution Center
                      valid_from: '2026-10-21'
                      valid_from_tag:
                      valid_until:
                        date: '2027-04-19'
                        label:
                        days_until_expiry: 180
                        tag:
                        note:
                      ended:
                      activated_by:
                      notes:
                      is_sample_data: false
                      created_at: '2026-10-21T10:00:00Z'
                      updated_at: '2026-10-21T10:00:00Z'
                    esignature:
                    behavior_history:
                      limit: 20
                      items: []
                    notices:
                    - key: no_email
                      icon: circle-info
                      message: Priya Shah has no email address on file and no personal
                        PIN or mobile number to confirm it's them, so they can't sign
                        in the app. If they signed on paper, use Activate instead.
                    permissions:
                      can_send_for_signature: false
                      can_activate: true
                      can_invalidate: false
                      can_record_employee_withdrawal: false
                      can_discard_draft: true
                      can_resend_signing_link: false
                      can_download_signed_waiver: false
                      can_start_new_waiver: false
                      can_revoke_own_waiver: false
                    actions:
                    - key: activate
                      label: Activate
                      icon: check
                      tone: success
                      placement: primary
                      confirm:
                        title: Activate this waiver?
                        message: Mark this waiver active and on file? Use this when
                          the employee has signed outside the app.
                        confirm_label: Activate
                    - key: discard_draft
                      label: Discard draft
                      icon: trash-can
                      tone: danger
                      placement: menu
                      reason: manual
                      discard: true
                      confirm:
                        title: Discard this draft?
                        message: Discard this draft? It was never sent, so Priya Shah
                          isn't told.
                        confirm_label: Discard
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks `read:meal_compliance` (`insufficient_permissions`),
            the app is not on for the organization (`app_disabled`), the caller may
            not open it (`app_access_denied`), or the waiver belongs to someone this
            caller may not see (`forbidden`).
        '404':
          description: No such waiver in this organization, or one hidden from the
            caller's own list (`not_found`); or the Meal Compliance app is not installed
            (`app_not_found`).
  "/meal_compliance/waivers/{id}/send_for_signature":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Send for Signature — send the employee the waiver to sign (by email,
        or in the app)
      description: |
        The web page's **Send for Signature**, through the same writer
        (`MealCompliance::WaiverActions`): a draft, or a pending waiver whose
        signature request never went out or lapsed unsigned (a new request is
        then sent). An employee with an email is mailed a signing link; one
        with no email is asked to sign in the app, where they confirm it is
        them with a personal PIN, a password they use or a code texted to
        their mobile. Refused (422) where the organization does not offer
        e-signature (`esignature_off`: the "Offer e-signature collection"
        setting; `esignature_unavailable`: the E-Signature app), the employee
        has left (`former_member`), has no email AND no way to confirm it is
        them in the app (`no_email`), or Document
        Signing is switched off (`signing_portal_closed`); refused (409) where
        the waiver is already out or past being a draft (`not_sendable`) or its
        term has ended (`term_ended`) — each with the sentence the web page shows.

        Offered when `permissions.can_send_for_signature` on `GET /meal_compliance/waivers/{id}`.
        Requires a caller who manages the waiver (HR, an admin, a Meal
        Compliance app admin, or the employee's line manager) and the wide
        `write:meal_compliance` scope.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          "$ref": "#/components/responses/MealComplianceWaiverActionForbidden"
        '404':
          description: No such waiver in this organization (`not_found`).
        '409':
          description: The waiver's state refuses it (`not_sendable`, `term_ended`)
            — a stale button or a double tap. `error.details.page` is the page as
            it is.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '422':
          description: The organization or the employee refuses it (`esignature_off`,
            `esignature_unavailable`, `former_member`, `no_email`, `signing_portal_closed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '502':
          description: E-Signature refused or failed to send (`send_failed`); worth
            a retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/waivers/{id}/activate":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Activate — put the waiver on file without an in-app signature
      description: |
        The web page's **Activate** ("the employee signed outside the app"),
        through the same writer: a draft, or a pending waiver with no live
        signature request out. Records who activated it (`waiver.activated_by`)
        and starts it today when it has no Valid From. Refused (409) for any
        other state (`not_activatable`) or a term that already ended
        (`term_ended`); refused (422) for a former employee (`former_member`)
        or a waiver that does not validate (`invalid`); refused (403) on the
        caller's own waiver (`own_waiver`) — nobody attests their own
        signature: another HR or app administrator activates it, or it is sent
        to them for signature.

        Offered when `permissions.can_activate`. Requires a caller who manages
        the waiver and the wide `write:meal_compliance` scope.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: As every action (see MealComplianceWaiverActionForbidden),
            or the waiver is the caller's own (`own_waiver`, with `error.details.page`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '404':
          description: No such waiver in this organization (`not_found`).
        '409':
          description: The waiver's state refuses it (`not_activatable`, `term_ended`).
            `error.details.page` is the page as it is.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '422':
          description: The employee has left (`former_member`), or the waiver did
            not validate (`invalid`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/waivers/{id}/invalidate":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Invalidate, Employee withdrew it, or Discard
      description: |
        The web page's three ending buttons, through the same writer.
        `reason` records WHO ended the waiver — legally opposite facts on a
        record with no edit route, so only these two are accepted (absent is
        `manual`; anything else is 400 and nothing is written):

        * `manual` — **Invalidate**, on the employer's initiative.
        * `employee_revoked` — **Employee withdrew it**: the employee's own
          notice, recorded by a manager. On a waiver not yet signed it is
          recorded as declined ("Employee declined it"); it never took effect.
        * On a **draft**, or a pending waiver whose signature request never
          went out (**Discard draft** / **Discard (never sent)**), the reason
          is ignored: it is recorded as discarded and nobody is notified — it
          was never sent.

        The employee is notified of an
        ending (not of a discard); when they could not be, the answer's `tone`
        is `warning` and the message says to tell them directly. Refused (409)
        for an expired or ended waiver (`not_invalidatable`) and for an active
        waiver whose term ended before the expiry sweep (`term_ended` — it
        reads Expired already).

        Offered when `permissions.can_invalidate`,
        `can_record_employee_withdrawal` or `can_discard_draft` (each action in
        `actions` carries the `reason` to send). Requires a caller who manages
        the waiver and the wide `write:meal_compliance` scope.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  enum:
                  - manual
                  - employee_revoked
                  default: manual
                discard:
                  type: boolean
                  default: false
                  description: The discard_draft action's press (send it as the action
                    says). A waiver sent since the page was read is then refused with
                    `state_changed`, never cancelled with a notice to the employee.
            example:
              reason: employee_revoked
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          "$ref": "#/components/responses/MealComplianceWaiverActionForbidden"
        '404':
          description: No such waiver in this organization (`not_found`).
        '400':
          description: "`reason` is not `manual` or `employee_revoked` (`invalid_parameter`,
            `details.field: reason`). Nothing was written."
        '409':
          description: The waiver's state refuses it (`not_invalidatable` — already
            ended, including by another door a moment ago; `term_ended`; `state_changed`
            — Discard on a waiver another door sent for signature a moment ago). `error.details.page`
            is the page as it is.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '500':
          description: The write failed (`invalidate_failed`); nothing changed, worth
            a retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/waivers/{id}/resend_signature":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Resend signing link / Remind to sign — remind the employee of their
        live link
      description: |
        The web page's **Resend signing link** — **Remind to sign** for a
        waiver sent to sign in the app (an employee with no email), which
        reminds them in the app and mails nothing — through the same writer and
        E-Signature's own reminder: only for a request that is out and still
        live, on an organization that offers e-signature (`not_resendable`
        otherwise — a lapsed request is Send for Signature's), never for a
        former employee (`former_member`). A failure E-Signature reports is
        `resend_failed`.

        Offered when `permissions.can_resend_signing_link`. Requires a caller
        who manages the waiver and the wide `write:meal_compliance` scope.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          "$ref": "#/components/responses/MealComplianceWaiverActionForbidden"
        '404':
          description: No such waiver in this organization (`not_found`).
        '409':
          description: No live signing link to resend (`not_resendable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '422':
          description: The employee has left (`former_member`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '502':
          description: E-Signature refused or failed the reminder (`resend_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/waivers/{id}/revoke":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Revoke my waiver / Withdraw before signing — the waiver's own employee
      description: |
        The employee withdraws their OWN waiver — My Breaks' **Revoke my
        waiver** (an agreement in force or starting later) or **Withdraw before
        signing** (one awaiting their signature, recorded as declined), through
        the same writer as the web. They are not sent the invalidation notice
        (it would ask them to re-sign what they just withdrew); whoever must
        replace it is told.

        Only the caller's own, real waiver is found — anyone else's, a sample
        waiver or a draft a manager discarded is 404, so an id never confirms
        a waiver exists. A waiver with nothing left to revoke is 409
        (`not_revocable`). `write:own_meal_compliance` is enough. This is My
        Breaks' own door, open to anyone on their own waiver, so it does not
        follow `permissions.can_revoke_own_waiver`, which the waiver page
        withholds from someone who manages the waiver (they record the same
        fact with Employee withdrew it).
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The token lacks `write:meal_compliance` / `write:own_meal_compliance`
            (`insufficient_permissions`), the app is not open to the caller, or an
            admin is viewing as the employee (`forbidden`).
        '404':
          description: Not the caller's own real waiver (`not_found`).
        '409':
          description: Nothing left to revoke (`not_revocable` — ended, expired, or
            ended by another door a moment ago).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '500':
          description: The write failed (`revoke_failed`); nothing changed, worth
            a retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/waivers/{id}/decline":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Decline to Sign — the waiver's own employee refuses its signing request,
        with an optional reason
      description: |
        The **Decline** beside Review & Sign, in the app (the prototype's
        "Decline to Sign" sheet): the employee refuses their pending waiver's
        signing request, through E-Signature's own writer — the signing page's
        Decline — so the signer row, the request, the audit trail the signing
        certificate prints and the sender's email and bell are written the
        same way. The waiver then ends as declined by the employee (it never
        took effect), and its E-Signature status reads Declined with the
        reason.

        Offered exactly where Review & Sign is — My Breaks'
        `pending_waiver.can_decline` / `decline` sheet, and the waiver page's
        `permissions.can_decline_to_sign`: the caller's own, real waiver,
        awaiting their signature on a live request (sent, not yet answered,
        link unexpired, Document Signing on). Where it is offered, Withdraw
        before signing is not (both end the waiver as declined by the
        employee). The caller is the signed-in member the signer row belongs
        to, so the signing link and its verification code are not asked for —
        deliberately: the code proves who holds a link, and this caller is
        already authenticated as that person. An admin's View-as never
        declines for the person (403). `write:own_meal_compliance` is enough.
        A decline of theirs already on record (a double tap, a lost answer)
        answers the same receipt.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  nullable: true
                  maxLength: 1000
                  description: Optional. Shown to the sender and to whoever has authority
                    over the request.
            example:
              reason: I'd rather keep my standard meal breaks for now.
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '400':
          description: "`reason` is not text (`invalid_parameter`, `details.field:
            reason`). Nothing was written."
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The token lacks `write:meal_compliance` / `write:own_meal_compliance`
            (`insufficient_permissions`), the app is not open to the caller, or an
            admin is viewing as the employee (`forbidden`).
        '404':
          description: Not the caller's own real waiver (`not_found`).
        '409':
          description: Not waiting for the caller's signature (`not_declinable` —
            never sent, already signed, the request closed, the link expired or Document
            Signing is off; My Breaks' `signing.note` says which).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '422':
          description: The reason is over 1,000 characters (`reason_too_long`). Nothing
            was written.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '500':
          description: The write failed (`decline_failed`); nothing changed, worth
            a retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/waivers/{id}/sign":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Sign — the app's own Sign Document on its Review & Sign sheet
      description: |
        The waiver's own employee signs their pending waiver with the signature
        their app captured, the date and their consent, through E-Signature's
        own writer — the signing page's Sign — so the field values, the consent
        in the audit trail, the executed copy, the certificate and the copies
        mailed are the same, and the audit row records that the signature came
        from the signed-in app (`signed_via`). Once the executed copy is made the
        waiver goes on file; the answer is the waiver page as it is then. When it
        did not go on file — the executed copy could not be produced, or the
        waiver could not be put in force — the answer is still 200, with `tone:
        warning` and a `message` saying so. Signing again after a signature
        that went through (a double tap, a lost answer) answers the same receipt.

        The sheet shows the waiver read-only in a web view (My Breaks'
        `signing.web_view_url`) and draws the signature pad, consent, Sign
        Document and Decline itself. Offered where `signing.can_sign_in_app` /
        `permissions.can_sign_in_app` is true: the caller's own real waiver,
        awaiting their signature on a live request, before its term ends. The caller is the
        signed-in member the signer row belongs to, so the signing link and its
        verification code are not asked for — deliberately, as for Decline. An
        admin's View-as never signs for the person (403).
        `write:own_meal_compliance` is enough.
      parameters:
      - "$ref": "#/components/parameters/MealComplianceWaiverId"
      - "$ref": "#/components/parameters/MealComplianceLocale"
      - "$ref": "#/components/parameters/MealComplianceIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - signature
              - consent
              properties:
                signature:
                  type: object
                  required:
                  - type
                  - value
                  properties:
                    type:
                      type: string
                      enum:
                      - typed
                      - drawn
                    value:
                      type: string
                      description: 'typed: the name (up to 200 characters). drawn:
                        a PNG as a data URL (`data:image/png;base64,…`, up to 256
                        KB).'
                    font:
                      type: string
                      enum:
                      - cursive
                      - elegant
                      - formal
                      description: typed only; cursive when absent.
                date:
                  type: string
                  format: date
                  nullable: true
                  description: Their own calendar day (YYYY-MM-DD); their today where
                    they are when absent.
                consent:
                  type: boolean
                  description: true — "I agree that my electronic signature is legally
                    binding". Recorded in the audit trail.
            example:
              signature:
                type: typed
                value: Marcus Bell
                font: cursive
              date: '2026-10-23'
              consent: true
      responses:
        '200':
          "$ref": "#/components/responses/MealComplianceWaiverActionDone"
        '400':
          description: "`signature` is not an object, or `date` is not text (`invalid_parameter`,
            `details.field`). Nothing was written."
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The token lacks `write:meal_compliance` / `write:own_meal_compliance`
            (`insufficient_permissions`), the app is not open to the caller, or an
            admin is viewing as the employee (`forbidden`).
        '404':
          description: Not the caller's own real waiver (`not_found`).
        '409':
          description: Not waiting for the caller's signature (`not_signable` — never
            sent, declined, the request closed, the link expired, Document Signing
            is off, or the term has ended).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '422':
          description: No consent (`consent_required`), a signature the signed copy
            cannot render (`signature_invalid`), a date that is not one (`date_invalid`),
            a drawn signature over 256 KB (`signature_too_large`), or a required field
            the app cannot fill (`fields_missing`). Nothing was written.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
        '500':
          description: A write was refused (`sign_failed`); nothing was saved, worth
            a retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceWaiverActionError"
  "/meal_compliance/my_breaks":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: My Breaks — the caller's own waiver status, break pattern and waiver
        history
      description: |
        Every role gets the same shape: an employee, a line manager and an HR
        admin each see only **their own** waivers and break pattern here (the team
        views are `/meal_compliance/dashboard` and `/meal_compliance/waivers`).

        Sections:
        * `pending_waiver` — a waiver awaiting the caller's signature (the web
          banner). `signing.can_sign` with `signing.sign_url` is the Review & Sign
          button — the caller's own live signing link; otherwise `signing.note`
          says why not (never sent, link expired, request voided, …) and
          `signing.state` names the state for a client that branches.
        * `current_waiver` — the agreement on the "Current waiver status" card: in
          force today, else one starting later (`not_yet_in_effect`) or one whose
          term has passed (`expired`). Null when there is none, with
          `no_current_waiver` carrying the empty state.
        * `break_pattern` — the newest 10 detected break-pattern windows ("Oct 1 –
          Oct 15: skipped 11 of 14 days"), observations only, with the uncapped
          `total` and a `truncation_note` when the list is cut.
        * `waiver_history` — every other waiver (ended, expired), newest first.
        * `drafts` — waivers the caller's manager is still preparing: counted, not
          listed — nothing is in effect and there is nothing to sign.

        `signing.web_view_url` is the waiver as a read-only document for the app's
        Review & Sign sheet; `signing.can_sign_in_app` with its `sign` sheet is the
        app's own Sign Document (`POST /meal_compliance/waivers/{id}/sign`).
        `can_decline` with its `decline` sheet is the prototype's Decline beside
        Review & Sign — `POST /meal_compliance/waivers/{id}/decline`, offered
        exactly where `signing.can_sign` is (not on a read-only token, nor
        under an admin's View-as). Where it is offered, `can_withdraw` is false:
        Decline replaces Withdraw before signing, which stays for a waiver
        never sent or whose link lapsed.

        `can_withdraw` / `can_revoke` and their `*_confirm` sentences are the web's
        Withdraw before signing / Revoke my waiver controls (the employee ends
        their own agreement); they are false on a read-only token, as on
        Waiver Details. A sample waiver is never revocable; a real person
        never sees sample rows here. `signed_copy` appears only where E-Signature
        would let the caller download it (`GET /esignature/{id}` answers a
        time-limited `signed_document_url`).

        Labels and the page's sentences are written in the language the client
        asks for (`locale`, then Accept-Language, then the caller's own); the
        break-pattern row `label` / `action_label` and a past waiver's `ended_reason`
        are still English, as on the web page. The response costs a fixed number
        of queries whatever the caller's history.
      parameters:
      - name: locale
        in: query
        required: false
        schema:
          type: string
          example: es
        description: The language for the sentences and labels this response writes.
          Omitted, the Accept-Language header decides, then the caller's own language.
      responses:
        '200':
          description: The caller's My Breaks record.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceMyBreaks"
              example:
                title: My Meal Breaks
                intro: Your meal-break waiver status and recent break history — what's
                  on file about your breaks, visible to you.
                pending_waiver:
                  id: 50
                  status: pending_signature
                  status_label: Pending Signature
                  tone: warning
                  behavior_state: waiving
                  elections: []
                  elections_summary: Waiving meal breaks
                  jurisdiction_name: Oregon
                  location:
                    id: 31
                    name: Downtown Store
                  valid_from: '2026-10-21'
                  valid_until:
                    date: '2027-10-21'
                    label:
                    days_until_expiry: 365
                    tag:
                  notes:
                  is_sample_data: false
                  updated_at: '2026-10-21T09:12:44Z'
                  signed_copy:
                  details_path: "/apps/meal-compliance/waivers/50"
                  heading: A meal-break waiver is waiting for your signature.
                  terms: Signing records your choice to waive meal breaks in Oregon.
                    Review it before signing — this is your decision, not a requirement.
                  signing:
                    state: ready
                    can_sign: true
                    sign_url: https://officechat.workforce.mangoapps.com/sign/9f3c…
                    web_view_url: https://officechat.workforce.mangoapps.com/m/apps/meal-compliance/88/document
                    can_sign_in_app: true
                    sign:
                      label: Sign Document
                      signature_label: Signature
                      signature_types:
                      - typed
                      - drawn
                      signature_fonts:
                      - cursive
                      - elegant
                      - formal
                      date_label: Date
                      date: '2026-10-23'
                      consent_label: I have read the electronic records disclosure
                        and agree to sign electronically. My electronic signature
                        is legally binding.
                      disclosure:
                        title: Electronic records and signatures disclosure
                        text: Harbor & Pine is asking you to sign this document electronically…
                        paragraphs:
                        - Harbor & Pine is asking you to sign this document electronically…
                        version: '2026-10-24'
                      hint: Sign, check the date, then confirm that your electronic
                        signature is legally binding.
                    note:
                    link_expires_at: '2026-11-20T09:12:44Z'
                  can_decline: true
                  decline:
                    label: Decline
                    title: Decline to Sign
                    message: Are you sure you want to decline?
                    confirm_label: Decline
                    reason_field:
                      label: Reason (optional)
                      required: false
                      max_length: 1000
                  can_withdraw: false
                  withdraw_confirm:
                  sample_note:
                pending_count: 1
                current_waiver:
                  id: 31
                  status: active
                  status_label: Active
                  tone: success
                  behavior_state: waiving
                  elections:
                  - period_ordinal: 1
                    label: 1st meal period
                    waived: true
                    choice_label: Waived
                  elections_summary: '1st meal period: Waived'
                  jurisdiction_name: California
                  location:
                    id: 31
                    name: Downtown Store
                  valid_from: '2025-10-30'
                  valid_until:
                    date: '2026-10-30'
                    label:
                    days_until_expiry: 9
                    tag:
                      kind: expiring
                      label: Expires in 9 days
                      tone: warning
                  notes: Agreed at the store meeting.
                  is_sample_data: false
                  updated_at: '2026-09-02T16:20:11Z'
                  signed_copy:
                    esignature_request_id: 812
                    download_path: "/apps/esignature/requests/812/download"
                  details_path: "/apps/meal-compliance/waivers/31"
                  choice_label: Waiving meal breaks
                  in_force: true
                  start_sentence: In effect since Oct 30, 2025.
                  end_sentence: Valid until Oct 30, 2026.
                  expiry:
                    expiring_soon: true
                    label: Expires in 9 days
                    renewed: false
                    renewed_note:
                  can_revoke: true
                  revoke_explainer: Changed your mind? You can revoke a meal-break
                    waiver at any time. It goes on file as withdrawn by you, so anyone
                    who reviews it sees the decision was yours.
                  revoke_confirm: Revoke your meal-break waiver? You'll go back to
                    taking your meal breaks as scheduled.
                  sample_note:
                no_current_waiver:
                break_pattern:
                  total: 12
                  limit: 10
                  truncation_note: Showing your 10 most recent entries of 12.
                  empty_state:
                  items:
                  - id: 902
                    label: 'Oct 1 – Oct 15: skipped 11 of 14 days'
                    detected_pattern: waiving
                    previous_pattern: taking
                    window_start: '2026-10-01'
                    window_end: '2026-10-15'
                    detected_at: '2026-10-15T06:00:00Z'
                    days_analyzed: 14
                    days_waived: 11
                    days_taken: 3
                    action_label:
                waiver_history:
                  total: 1
                  items:
                  - id: 22
                    status: invalidated
                    status_label: Withdrawn
                    tone: neutral
                    behavior_state: waiving
                    elections: []
                    elections_summary: Waiving meal breaks
                    jurisdiction_name: California
                    location:
                      id: 31
                      name: Downtown Store
                    valid_from: '2024-10-30'
                    valid_until:
                      date: '2025-10-29'
                      label:
                      days_until_expiry: -357
                      tag:
                    notes:
                    is_sample_data: false
                    updated_at: '2025-08-11T10:02:00Z'
                    signed_copy:
                    details_path: "/apps/meal-compliance/waivers/22"
                    choice_label: Waiving
                    valid_label: Oct 2024 – Oct 2025
                    updated_on: Aug 11, 2025
                    ended_reason: You withdrew your agreement to waive meal breaks.
                drafts:
                  count: 1
                  note: Your manager is preparing 1 waiver that hasn’t been sent yet.
                    It isn’t in effect, and there is nothing for you to sign.
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks `read:meal_compliance` (`insufficient_permissions`),
            the app is not on for the organization (`app_disabled`), or the caller
            may not open it (`app_access_denied`).
        '404':
          description: The Meal Compliance app is not installed (`app_not_found`).
  "/meal_compliance/break_compliance":
    get:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: The Break Compliance report (tiles, rules in force, findings)
      description: |
        Meal and rest break findings from recorded time — what the break
        engine recorded when each shift was clocked out — for the whole
        organization. Findings name individual employees and their
        timekeeping, so only a business admin, a Meal Compliance app admin or
        an HR admin may read them, as on the web.

        **Tiles** count the FILTERED set, before the page limit:
        `findings`, `unresolved` (left out while a `status` is selected —
        under Resolved it would be a structural 0, under Unresolved a copy of
        Findings), `employees_affected`, and `by_period` whose three `parts`
        (Meal / Rest / Unbroken work) add up to Findings. A tile or part with
        a `filter` opens the list narrowed that way; one with none (a zero)
        does not.

        **Filters** (`filters`) are the All / Unresolved / Resolved chips,
        counted under every OTHER filter, so a chip never advertises rows the
        list hides; `selected` marks the current one.

        **Rules in force** lists each break rule resolving for the
        organization's active locations, with the locations it covers.
        `state` is `listed`, `none` (no location's jurisdictions resolve a
        break rule, so nothing is being evaluated) or `unavailable` (the rules
        could not be worked out just now — `total` is then null, and an empty
        report means nothing either way).

        **Empty state.** When no finding matches, `empty_state.kind` says why:
        `rules_unavailable`, `no_rules`, `filtered`, or `none_recorded` — and
        none of them is an all-clear.

        A filter value that is not recognised is not applied: the list shows
        what it would without it, and `meta.filter_ignored` /
        `meta.ignored_filters` say which. An employee id naming nobody in the
        organization IS applied (nothing matches) and sets
        `applied_filters.employee_unknown`.

        The response costs a fixed number of queries whatever the number of
        findings, people or locations.
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - all
          - open
          - unresolved
          - resolved
          default: all
        description: "`open` (alias `unresolved`) — not yet resolved; `resolved` —
          resolved."
      - name: period_kind
        in: query
        required: false
        schema:
          type: string
          enum:
          - meal
          - rest
          - any
        description: "`any` is Unbroken work — findings that belong to no single meal
          or rest period."
      - name: event_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - too_early
          - too_late
          - missing
          - short
          - second_meal_missing
          - unbroken_interval_exceeded
      - name: user_id
        in: query
        required: false
        schema:
          type: integer
        description: One employee's findings (a person in this organization).
      - name: from
        in: query
        required: false
        schema:
          type: string
          format: date
        description: Work date on or after (YYYY-MM-DD).
      - name: to
        in: query
        required: false
        schema:
          type: string
          format: date
        description: Work date on or before (YYYY-MM-DD).
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Clamped to the last page, so an out-of-range page returns the
          last page, never an empty list.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: locale
        in: query
        required: false
        schema:
          type: string
          example: es
        description: The language for the words this response writes (labels, captions,
          notes). Omitted, the Accept-Language header decides, then the caller's own
          language.
      responses:
        '200':
          description: The report. Also sets X-Total-Count, X-Total-Pages, X-Current-Page
            and X-Per-Page.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MealComplianceBreakComplianceReport"
              example:
                applied_filters:
                  status: all
                  event_type:
                  period_kind:
                  from:
                  to:
                  employee:
                  employee_unknown: false
                  active: false
                enforcement:
                  mode: observe
                  label: Observing only
                  description: 'Each shift is checked when it is clocked out and what
                    it finds is listed below. This mode reaches nobody: no employee
                    is prompted, no assignment is blocked, and no pay is adjusted.'
                  icon: eye
                  tone: info
                  can_change: true
                tiles:
                - key: findings
                  label: Findings
                  value: 7
                  caption: All findings on file
                  icon: clipboard-list
                  tone: primary
                - key: unresolved
                  label: Unresolved
                  value: 5
                  caption: Still open on this backlog
                  icon: circle-exclamation
                  tone: danger
                  filter:
                    status: open
                - key: employees_affected
                  label: Employees affected
                  value: 6
                  caption: With at least one finding
                  icon: users
                  tone: info
                - key: by_period
                  label: Meal / Rest / Unbroken work
                  value: 7
                  caption: The three add up to Findings
                  hint: Unbroken work counts findings that belong to no single meal
                    or rest period — worked longer than the maximum unbroken stretch.
                  icon: utensils
                  tone: warning
                  parts:
                  - period_kind: meal
                    label: Meal
                    count: 4
                    selected: false
                    filter:
                      period_kind: meal
                  - period_kind: rest
                    label: Rest
                    count: 2
                    selected: false
                    filter:
                      period_kind: rest
                  - period_kind: any
                    label: Unbroken work
                    count: 1
                    selected: false
                    filter:
                      period_kind: any
                rules_in_force:
                  state: listed
                  total: 2
                  note:
                  items:
                  - rule_id: 88
                    summary: 'Breaks: meal period after 5h; second meal period after
                      10h; 10-min rest every 4h'
                    location_count: 2
                    locations:
                    - id: 31
                      name: North Warehouse
                    - id: 32
                      name: South Bay Store
                    more_locations_count: 0
                  can_manage_rules: true
                filters:
                - key: all
                  label: All
                  count: 7
                  selected: true
                - key: open
                  label: Unresolved
                  count: 5
                  selected: false
                - key: resolved
                  label: Resolved
                  count: 2
                  selected: false
                options:
                  event_types:
                  - key: missing
                    label: Missing
                  - key: too_late
                    label: Started too late
                  period_kinds:
                  - key: meal
                    label: Meal
                  - key: rest
                    label: Rest
                  - key: any
                    label: Unbroken work
                items:
                - id: 5501
                  work_date: '2026-10-02'
                  employee:
                    id: 5120
                    name: Jamal Reed
                    initials: JR
                    avatar_url: https://ui-avatars.com/api/?name=Jamal%20Reed
                  finding:
                    type: missing
                    label: Missing
                    tone: danger
                  period:
                    kind: meal
                    ordinal: 1
                    label: 1st meal period
                  description: 1st meal period was not taken
                  severity: violation
                  mode:
                    key: observe
                    label: Observe
                    tone: neutral
                  status: open
                  status_label: Unresolved
                  resolution:
                  attendance_record_id: 99812
                  detected_at: '2026-10-02T18:04:11Z'
                empty_state:
                meta:
                  total_count: 7
                  total_pages: 1
                  current_page: 1
                  per_page: 25
                  has_next_page: false
                  has_prev_page: false
                permissions:
                  can_resolve: true
                  can_change_enforcement_mode: true
                  can_manage_rules: true
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks `read:meal_compliance`, or holds only the own-tier
            `read:own_meal_compliance` (`insufficient_permissions`); the app is not
            on for the organization (`app_disabled`); the caller may not open it (`app_access_denied`);
            or the caller is not a business admin, Meal Compliance app admin or HR
            admin (`forbidden`).
        '404':
          description: The Meal Compliance app is not installed (`app_not_found`).
  "/meal_compliance/break_compliance/{id}/resolve":
    post:
      tags:
      - Meal Compliance
      security:
      - BearerAuth: []
      summary: Resolve a break finding with a resolution note
      description: |
        The prototype's "Resolve this finding" sheet: an optional
        **Resolution note** — the reason of record if the finding is ever
        questioned in a wage-and-hour review — then **Mark resolved**. Records
        who resolved the finding, when, and the note, and answers with the
        finding as the report's `items[]` draws it, so the client swaps the
        card in place (its `status` becomes `resolved` and `resolution` is
        filled). Show `message` as the confirmation toast.

        **Resolving is permanent.** A resolved finding cannot be reopened, and
        re-running the evaluator does not restore it. Resolving an
        already-resolved finding answers `409 already_resolved` with the finding
        as it was first resolved — its adjudicator, time and note are never
        replaced — so the client can redraw the card from `error.details.finding`.
        Two people resolving the same finding at once cannot both win: the
        second is answered 409.

        **Resolving records a decision; it does not change pay.** To change
        what the day pays, correct the break on the employee's punch in Time &
        Attendance.

        Only the people who may read the report may resolve its findings: a
        business admin, a Meal Compliance app admin or an HR admin — the web
        page's gate. A line manager or an employee is answered `403 forbidden`.
        Show the Resolve action only while the report's
        `permissions.can_resolve` is true and the card's `status` is `open`.

        The response costs a fixed number of queries.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The finding's `id` (a report `items[].id`).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolution_note:
                  type: string
                  nullable: true
                  description: Optional. Stored with surrounding whitespace removed;
                    blank or omitted stores no note. A value that is not a string
                    (a list, an object, a number) is refused with 400 rather than
                    dropped.
            example:
              resolution_note: Break was taken but not clocked; break added to the
                punch.
      responses:
        '200':
          description: The finding was resolved.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - finding
                properties:
                  message:
                    type: string
                    description: The confirmation, in the caller's language ("Finding
                      marked resolved.").
                  finding:
                    "$ref": "#/components/schemas/MealComplianceBreakFinding"
              example:
                message: Finding marked resolved.
                finding:
                  id: 3121
                  work_date: '2026-10-06'
                  employee:
                    id: 5133
                    name: Omar Haddad
                    initials: OH
                    avatar_url: https://ui-avatars.com/api/?name=Omar%20Haddad
                  finding:
                    type: missing
                    label: Missing
                    tone: danger
                  period:
                    kind: meal
                    ordinal: 1
                    label: 1st meal period
                  description: 1st meal period was not taken
                  severity: violation
                  mode:
                    key: observe
                    label: Observe
                    tone: neutral
                  status: resolved
                  status_label: Resolved
                  resolution:
                    resolved_at: '2026-10-21T15:42:10Z'
                    resolved_by:
                      id: 1
                      name: Anup Kumar
                    note: Break was taken but not clocked; break added to the punch.
                  attendance_record_id: 88412
                  detected_at: '2026-10-06T23:05:00Z'
        '400':
          description: "`resolution_note` was sent but is not a string (`invalid_parameter`,
            `error.details.field: resolution_note`). Nothing was written."
          content:
            application/json:
              example:
                error:
                  code: invalid_parameter
                  message: resolution_note must be a string
                  details:
                    field: resolution_note
        '401':
          description: Missing or invalid Bearer token.
        '402':
          description: The organization's monthly API allowance is spent (`api_credits_exhausted`).
        '403':
          description: The token lacks the wide `write:meal_compliance` — a read-only
            or own-tier token (`insufficient_permissions`); the caller is not a business
            admin, Meal Compliance app admin or HR admin (`forbidden`, "Compliance
            reports are available to HR and administrators."); the app is not on for
            the organization (`app_disabled`); or the caller may not open it (`app_access_denied`).
            Nothing was written.
        '404':
          description: No such finding in the caller's organization (`not_found`,
            "That finding no longer exists.") — a finding of another organization
            is never reached — or the Meal Compliance app is not installed (`app_not_found`).
        '409':
          description: The finding was already resolved (`already_resolved`). Its
            first adjudication is kept; `error.details.finding` is the finding as
            it stands, to redraw the card.
          content:
            application/json:
              example:
                error:
                  code: already_resolved
                  message: This finding was already resolved, so it cannot be resolved
                    again.
                  details:
                    finding:
                      id: 3121
                      status: resolved
                      status_label: Resolved
                      resolution:
                        resolved_at: '2026-10-21T15:40:02Z'
                        resolved_by:
                          id: 77
                          name: Priya Shah
                        note: Corrected on the punch.
        '422':
          description: The finding could not be saved (`validation_error`, with the
            field errors).
  "/frontline_execution/coverage":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The membership-rooted Coverage rollup (nodes / campaign lens)
      description: |
        Returns the rollup rooted at wherever the caller is a MEMBER, in one of two
        lenses. With no `node`, it opens on the children of the caller's topmost
        membership (a corporate member on their regions, a region member on their
        districts); an admin with no membership opens on the whole estate. `node`
        drills into a child location (a region → its districts, a district → the
        campaigns running there); `view` picks the lens; `status` filters the
        campaign lens.
      parameters:
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: 'The child location to drill into (a region → its districts,
          a district → its stores). The drill is LEVEL-aware: each step shows only
          the shallowest location tier directly beneath the node (its districts, say),
          skipping a store or a level-less location someone mis-parented directly
          under it. Inactive locations are never shown. Omitted, the response opens
          at the top of the caller''s membership. A node outside the span returns
          empty nodes rather than leaking.'
      - name: view
        in: query
        required: false
        schema:
          type: string
          enum:
          - nodes
          - campaign
        description: The lens. Omitted, the server picks the span's natural default
          (returned in `default_view`). `nodes` is coerced to `campaign` when the
          viewer has no child locations to descend into.
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - active
          - closed
          - all
          default: active
        description: Campaign-lens status filter. `active` (default) is the live work;
          `closed` the finished programmes; `all` both. Scheduled campaigns never
          appear — they have no minted work to roll up.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: Page of the campaign lens (25 per page).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Campaign-lens page size (clamped 1–100).
      responses:
        '200':
          description: The rollup for the caller's current node and lens.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageDashboard"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible (`app_disabled` / `forbidden`),
            the token lacks `read:execution` (`insufficient_permissions`), the Coverage
            surface is off (`surface_disabled`), or the caller is not in the campaign-author
            tier (`forbidden`).
  "/frontline_execution/coverage/nudge":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Nudge a district (or a campaign's lagging districts) — the rollup,
        not a task
      description: |
        Sends the rollup to the accountable manager(s) of a lagging node. Pass
        `node` for one district, or `campaign_id` for a campaign's lagging children.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                node:
                  type: integer
                  description: The location (district) to nudge — its accountable
                    manager receives the rollup.
                  example: 42
                campaign_id:
                  type: integer
                  description: Nudge the managers of the child nodes lagging on this
                    campaign (below 90%). Combine with `node` to narrow the scope.
                  example: 17
      responses:
        '200':
          description: The pass ran (including the all-nudged-today case, which reports
            nudged_count 0).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageNudgeResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `write:execution`,
            the Coverage surface is off (`surface_disabled`), or the caller is not
            in the campaign-author tier.
        '404':
          description: A `campaign_id` that isn't this tenant's (`not_found`).
        '422':
          description: Neither a node nor a campaign was given (`nothing_to_nudge`),
            or nothing is below the lag threshold (`nothing_behind`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageNudgeError"
  "/frontline_execution/campaigns/{id}/coverage":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: One campaign's coverage — flat by-location list plus optional hierarchy
        drill
      description: |
        Returns the campaign header, an SQL-counted `summary`, the worst-first
        `by_location` slice (each row carrying a coverage-health `status`), and
        the optional `?node=` hierarchy rollup. Everything is bounded to the
        caller's location span.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign to report on.
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: A location id to open the hierarchy under. Absent, `hierarchy.nodes`
          is the top of the caller's span; present, it is the children of this node
          with each subtree rolled up.
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: How many `by_location` rows to return (worst-first). Defaults
          to 25, clamped to 1–100. `has_more_locations` says whether the cut bit.
      responses:
        '200':
          description: The campaign's coverage for this caller's span.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CampaignCoverage"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `read:execution`
            (`insufficient_permissions`), the Coverage surface is off (`surface_disabled`),
            or the caller is not in the campaign-author tier.
        '404':
          description: No such campaign in this business (`not_found`).
  "/frontline_execution/campaigns/{id}/coverage/remind":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Remind everyone still outstanding on one campaign (span-bounded)
      description: |
        Queues a reminder to every person still holding outstanding work on
        this campaign inside the caller's span, and answers at once with who
        the queued pass is reminding — counted before anything is sent. The
        reminders go out in the background, under the same 200-reminder cap
        and 24-hours-per-person throttle as the web Coverage page's Remind
        button.

        - `reminded` — the people the queued pass is reminding (within the cap).
        - `skipped` — the people reminded in the last 24 hours; they are not
          reminded again.
        - `remaining` — the people past the cap; run Remind again to reach them.
        - `queued` — whether a pass was queued at all (false when everyone
          outstanding was reminded in the last 24 hours, or nobody is
          outstanding).

        When nobody is outstanding the answer is a clean 200 with every count
        0, `queued: false` and "Nobody has work outstanding on this campaign."

        When the caller's whole span holds fewer people than the company's
        smallest report group on a post a works council covers, no count is
        given: `reminded`, `skipped` and `remaining` are null, and
        `people_withheld`, `more_outstanding` and a count-free `message` are
        returned instead.

        **Required Scopes:** `write:execution`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:execution").
        A browser session is asked no scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign to remind on.
      responses:
        '200':
          description: The pass was queued (or there was nothing to queue — nobody
            outstanding, or everyone outstanding reminded in the last 24 hours).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CoverageRemindResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `write:execution`
            (`insufficient_permissions`), the Coverage surface is off (`surface_disabled`),
            or the caller is not in the campaign-author tier.
        '404':
          description: No such campaign in this business (`not_found`).
  "/frontline_execution/campaigns":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: List campaigns with a per-campaign coverage breakdown
      description: |
        Returns the tenant's campaigns, newest first, each with a `coverage`
        object rolling up its obligations by status within the caller's location
        span. The breakdown drives the Coverage screen's "By campaign" cards:
        `done`, `in_progress` (with its `awaiting_review` subset), `not_started`,
        `missed`, `blocked` and `unassigned` (plus the `total`/`percent` a
        client can render a bar from) — the same values the Coverage
        endpoint's campaign lens reports for each campaign. A post's `percent`
        is its PEOPLE (`unit: people`, with the `people` pair it is drawn
        from), as on the web Campaigns and Coverage lists.

        Paginated: send `page` (and optionally `limit`) and read `meta` to page.
        A caller that sends neither gets page 1 at the default size, exactly as
        before pagination was added. An optional `status` filter narrows the list
        to one campaign lifecycle state; a non-scalar or unknown value is ignored
        rather than erroring, and `meta.status_filter_ignored` says so.
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - draft
          - scheduled
          - active
          - closed
        description: Filter to one campaign lifecycle status. If omitted the whole
          list is returned. A value outside the enum (or an array) is ignored and
          `meta.status_filter_ignored` is set.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
        description: 1-based page number (default 1).
      - name: limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
        description: Page size (default and max are server-clamped).
      responses:
        '200':
          description: The campaign list for this page.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineExecutionCampaignsResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `read:execution`
            (`insufficient_permissions`), or the caller is not in the campaign-author
            tier (`forbidden`).
  "/frontline_execution/campaigns/held":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: List campaigns held in the publication release gate
      description: |
        Returns the scheduled campaigns awaiting publication approval, newest
        submission last (the gate is a queue). Capped and unpaginated — held work
        is rare. Each row carries who submitted it and when; the full release
        spec and whether the caller may decide it come from
        `GET /campaigns/{id}/release`.
      responses:
        '200':
          description: The held list (possibly empty).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/HeldCampaignsResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `read:execution`
            (`insufficient_permissions`), the Campaigns surface is off (`surface_disabled`),
            or the caller is not in the campaign-author tier.
  "/frontline_execution/campaigns/{id}":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Campaign detail — everything the web campaign page shows
      description: |
        One campaign's full detail. Lifecycle decides which panels are present:
        `coverage` / `launch_gaps` / `required_training` exist only after launch;
        `approval` / `readiness` / `writing_check` / `targets` / `audience_load` /
        `release` only before. `viewer.can` and `release` are the same predicates
        the web header and overflow menu draw from, so a client that honours them
        never offers a control the server refuses. There is no launch or submit
        endpoint on this API yet — `release.action` says which control the web
        offers and whether it is live.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign.
      responses:
        '200':
          description: The campaign detail.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/FrontlineCampaignDetail"
              example:
                campaign:
                  id: 734
                  name: Winter coat planogram
                  status: draft
                  status_label: Draft
                  priority: important
                  priority_label: Important
                  work_type: task
                  work_type_label: Task
                  starts_on: '2026-10-02'
                  ends_on: '2026-10-09'
                  releases_on:
                  launched: false
                  launched_at:
                  closed_at:
                viewer:
                  author_tier: true
                  edit_mode: draft
                  can:
                    view_coverage: false
                    view_history: false
                    edit: true
                    edit_translations: true
                    clone: true
                    close: false
                    delete: true
                    ask_question: true
                release:
                  state: outside_span
                  action: launch
                  enabled: false
                  blocked_reason: This campaign targets 1 location outside the locations
                    you manage. Ask an admin to launch it, or submit it for publication
                    approval.
                  requires_reason: false
                  reason_min_length:
                  locations_outside_span: 1
                approval:
                  required: false
                  state:
                coverage:
                launch_gaps:
                required_training:
                readiness:
                  ready: false
                  warnings:
                  - code: past_dated
                    message: The start date is in the past.
                  availability:
                    applicable: false
                    checked: false
                    failed: false
                writing_check:
                  measured_against: clarity_baseline
                  result:
                targets:
                  mode: locations
                  mode_label: 2 specific locations
                  count: 2
                  not_targeted_count:
                  concerns: false
                  locations:
                  - id: 11
                    name: Store A
                  locations_overflow: 1
                  dropped_count: 0
                  dropped: []
                  dropped_overflow: 0
                  unstaffed_count: 0
                  unstaffed: []
                  unstaffed_overflow: 0
                audience_load:
                  tier: has_room
                  overloaded: false
                  window_days: 7
                  headcount: 14
                  per_person: 1.4
                  total_open_items: 20
                  capacity: 490
                  counts:
                    campaign_items: 6
                    tasks: 9
                    must_reads: 3
                    training_due: 2
                  scheduled_minutes: 5880
                  estimated_minutes_total: 240
                  unestimated_items: 4
                  degraded_sources: []
                  recommendation: This group has room for this campaign.
                  computed_at: '2026-09-29T10:15:00Z'
                brief:
                  instructions_html: "<p>Reset the <strong>winter</strong> endcap.</p>"
                  instructions_text: Reset the winter endcap.
                  checklist:
                  - Strip the old set
                  - Photograph it
                  documents:
                  - id: 91
                    filename: planogram.pdf
                    byte_size: 48213
                    content_type: application/pdf
                    url: https://officechat.workforce.mangoapps.com/rails/active_storage/…
                setup:
                  work:
                    type: task
                    label: Task
                    description: Something to do, optionally a checklist. Lands in
                      the holder's tasks.
                    template_label: Endcap reset
                    inspection:
                  assignment:
                    strategy: claim_pool
                    label: Anyone at the store can claim it
                    timing: on_pickup
                    timing_label: Assigned when someone picks it up on shift.
                  targeting_label: 2 specific locations
                  owning_department:
                    id: 4
                    name: Merchandising
                  estimated_duration_minutes: 30
                  priority: important
                  priority_label: Important
                  recurrence:
                  occurrence_of:
                  eligibility_window:
                  on_miss:
                    raises_corrective_action: false
                  on_unfulfilled:
                    value: reassign_role
                    label: Give it to the role holder
                  close_behavior:
                    value: manual
                    label: I close it myself
                  proof:
                    captured_in_inspection: false
                    photo: true
                    signature: false
                  review:
                    mode: none
                    workflow:
                  certifications: []
                  required_response:
                  required_training: []
                shift_attention_reason:
                upcoming_shift_work:
                questions:
                  post_count: 2
                  question_count: 1
                  accepting_questions: true
                  can_post: true
                  max_body_length: 2000
                  threads:
                  - id: 501
                    body: Top shelf or eye-level?
                    author:
                      id: 31
                      name: Priya Shah
                      photo_url: https://ui-avatars.com/api/?name=Priya%20Shah&size=40&bold=true&color=ffffff&background=2e7b92
                    is_campaign_owner: false
                    parent_id:
                    created_at: '2026-09-28T16:02:11Z'
                    edited: false
                    replies:
                    - id: 502
                      body: Eye-level.
                      author:
                        id: 7
                        name: Anup Rao
                        photo_url: https://example.mangoapps.com/rails/active_storage/representations/redirect/eyJ.../avatar.jpg
                      is_campaign_owner: true
                      parent_id: 501
                      created_at: '2026-09-28T16:20:40Z'
                      edited: false
                  meta:
                    showing: 1
                    total: 1
                    truncated: false
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks an execution read
            scope, the Campaigns surface is off (`surface_disabled`), or the caller
            is neither in the author tier nor a collaborator on this campaign (`forbidden`).
        '404':
          description: No such campaign in this business (`not_found`).
  "/frontline_execution/campaigns/{id}/release":
    get:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: The release-review spec for one held/scheduled campaign
      description: |
        Composes the release-review payload for a draft or scheduled campaign.
        `sites`/`obligations` are the projected target size (one obligation per
        resolved location); `same_day` lists the other campaigns landing on the
        same start date and the total obligations that day. `approval.can_decide`
        is the approval engine's own authority check for the caller (false for the
        submitter — separation of duties — and for anyone outside the approver set
        or span).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The campaign to review.
      responses:
        '200':
          description: The release-review spec.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ReleaseReview"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `read:execution`,
            the Campaigns surface is off, or the caller is not in the author tier.
        '404':
          description: No such campaign in this business (`not_found`).
        '422':
          description: The campaign has already launched — nothing to release (`not_releasable`).
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Release a held campaign (approve its publication)
      description: |
        Approves the open publication request so the release gate opens. The
        campaign keeps its scheduled status and launches on its own date — this
        never launches it directly.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The held campaign to release.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  description: Optional note recorded on the approval action.
      responses:
        '200':
          description: Released — the publication request is approved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PublicationDecisionResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: App disabled/not accessible, the token lacks `write:execution`,
            the Campaigns surface is off, the caller is not in the author tier, or
            the caller may not decide THIS request (`not_authorized_to_approve` —
            e.g. they submitted it, or it's outside their span).
        '404':
          description: No such campaign in this business (`not_found`).
        '409':
          description: The campaign isn't awaiting release approval (`not_under_review`)
            or has already launched (`already_launched`).
  "/frontline_execution/campaigns/{id}/hold":
    post:
      tags:
      - Frontline Execution
      security:
      - BearerAuth: []
      summary: Hold a campaign (reject its publication)
      description: |
        Rejects the open publication request. A scheduled campaign is returned to
        draft so it won't auto-launch, and its author is notified with the reason.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:frontline_execution` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The held campaign to hold.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional reason surfaced to the author.
      responses:
        '200':
          description: Held — the publication request is rejected and the campaign
            returned to draft.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PublicationDecisionResult"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: Same fences as release, including `not_authorized_to_approve`
            when the caller may not decide this request.
        '404':
          description: No such campaign in this business (`not_found`).
        '409':
          description: Not awaiting approval (`not_under_review`) or already launched
            (`already_launched`).
  "/esignature":
    get:
      tags:
      - E-Signature
      summary: List signature requests
      description: |
        The signature requests in the business the caller may see, newest first
        (`created_at` descending). A business admin or an E-Signature app admin
        sees every request in the business. Anyone else sees the requests they
        created, the requests they are a signer on (once sent — a draft is never
        shown to its signers), the requests shared with them or with a group
        they belong to, and, for a manager, the requests their direct reports
        created. Policies signed in the app are left out.

        **Required Scopes:** `read:esignature`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:esignature"),
        checked after the E-Signature app gate.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: 'Only requests in this state. `pending` means a request still
          waiting on a signer (`sent`, `viewed` or `partially_signed`, less one every
          signer has signed whose signed copy is still being produced, which `partially_signed`
          lists); `draft` means a draft that is not scheduled; `scheduled` means a
          draft set to send itself at a later time (the request detail''s `scheduled:
          true`); every other value is one request status. An unrecognised value (or
          a repeated `status[]=`) is ignored and the list is returned unfiltered.'
        schema:
          type: string
          enum:
          - pending
          - scheduled
          - draft
          - sent
          - viewed
          - partially_signed
          - completed
          - declined
          - voided
          - expired
      - name: search
        in: query
        required: false
        description: Text to look for in the request title, or in a signer's name
          or email (case-insensitive substring). Empty, or a non-string value such
          as `search[]=`, means no search.
        schema:
          type: string
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Requests per page. Values above 50 are clamped to 50; zero, negative
          or non-numeric values are read as 1; an empty value uses the default.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: One page of signature requests. Pagination is also in the `X-Total-Count`
            / `X-Total-Pages` / `X-Per-Page` / `X-Current-Page` headers.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/EsignatureRequestSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`app_disabled` (E-Signature is not enabled for the business),
            `forbidden` (the app''s access rules leave the caller out) or `insufficient_permissions`
            ("Required scope: read:esignature" — the token lacks the scope).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the E-Signature app does not exist on this
            platform."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/esignature/pending":
    get:
      tags:
      - E-Signature
      summary: List requests waiting for the caller's signature
      description: |
        The requests still out for signature (`sent`, `viewed` or
        `partially_signed`) on which the caller has a signer row that has not
        signed or declined yet — matched on the caller's user id, or on their
        email for a signer row not yet linked to a user — newest first
        (`created_at` descending). Each entry is the request summary plus the
        caller's own place in it: their signer status, their signing order and
        whether it is their turn now. Policies signed in the app are left out.
        This list takes no filters.

        **Required Scopes:** `read:esignature`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:esignature"),
        checked after the E-Signature app gate.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Requests per page. Values above 50 are clamped to 50; zero, negative
          or non-numeric values are read as 1; an empty value uses the default.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: One page of requests waiting for the caller. Pagination is
            also in the `X-Total-Count` / `X-Total-Pages` / `X-Per-Page` / `X-Current-Page`
            headers.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/EsignaturePendingRequest"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`app_disabled` (E-Signature is not enabled for the business),
            `forbidden` (the app''s access rules leave the caller out) or `insufficient_permissions`
            ("Required scope: read:esignature" — the token lacks the scope).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the E-Signature app does not exist on this
            platform."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/esignature/templates":
    get:
      tags:
      - E-Signature
      summary: List signature templates
      description: |
        The active templates the business can send from — its own templates and
        the platform's system templates — newest first (`created_at`
        descending). Filter by category and search by name and description.

        **Required Scopes:** `read:esignature`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:esignature"),
        checked after the E-Signature app gate.
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        required: false
        description: Only templates in this category (exact match). Any other string
          returns an empty list; empty, or a non-string value such as `category[]=`,
          means every category.
        schema:
          type: string
          enum:
          - hr
          - legal
          - sales
          - general
          - compliance
          - onboarding
          - other
      - name: search
        in: query
        required: false
        description: Text to look for in the template name or description (case-insensitive
          substring). Empty, or a non-string value, means no search.
        schema:
          type: string
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Templates per page. Values above 50 are clamped to 50; zero,
          negative or non-numeric values are read as 1; an empty value uses the default.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: One page of templates. Pagination is also in the `X-Total-Count`
            / `X-Total-Pages` / `X-Per-Page` / `X-Current-Page` headers.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/EsignatureTemplateSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`app_disabled` (E-Signature is not enabled for the business),
            `forbidden` (the app''s access rules leave the caller out) or `insufficient_permissions`
            ("Required scope: read:esignature" — the token lacks the scope).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the E-Signature app does not exist on this
            platform."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/esignature/{id}":
    get:
      tags:
      - E-Signature
      summary: Get a signature request
      description: |
        One signature request with its signers in signing order, its fields and
        its history. The caller may read it when they are a business admin or
        an E-Signature app admin, its creator, a signer on it once it has been
        sent (a draft is never shown to its signers), the creator's manager, or
        someone it was shared with (directly or through a group). Anyone else
        gets the same 404 as a request that does not exist.

        A decline reason appears only to the people who answer for the request
        and to the signer who declined. `can_edit`, `can_send`, `can_resend`
        and `can_void` say what this caller and this token may do next.

        **Required Scopes:** `read:esignature`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:esignature"),
        checked after the E-Signature app gate.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Signature request ID
        schema:
          type: integer
      responses:
        '200':
          description: The signature request.
          content:
            application/json:
              schema:
                type: object
                required:
                - request
                properties:
                  request:
                    "$ref": "#/components/schemas/EsignatureRequestDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`app_disabled` (E-Signature is not enabled for the business),
            `forbidden` (the app''s access rules leave the caller out) or `insufficient_permissions`
            ("Required scope: read:esignature").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`app_not_found` (the E-Signature app does not exist on this
            platform) or `not_found` ("Signature request not found" — no such request
            in this business, or one the caller may not see).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/esignature/{id}/remind":
    post:
      tags:
      - E-Signature
      summary: Remind the signers whose turn it is
      description: |
        Sends a reminder to the signers on a request out for signature (`sent`,
        `viewed` or `partially_signed`) whose turn it is to sign — the request
        page's Remind. A signer later in the signing order is not reminded until
        it is their turn; when nobody is in turn the call answers 422. A request
        sent through an outside provider (DocuSign, Dropbox Sign) is reminded by
        that provider, and `reminded_count` is null. The request's creator,
        their manager or an administrator may remind; a policy signed in the
        app is Policy Hub's record and is refused here (422 `in_app_ceremony`).
        A reminder goes out to the signers at once and cannot be unsent.

        **Required Scopes:** `write:esignature`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:esignature"),
        checked after the E-Signature app gate. The request detail's
        `can_resend` is false for such a token.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Signature request ID
        schema:
          type: integer
      responses:
        '200':
          description: The reminders were sent. `message` says how many signers were
            reminded, how many later signers were left until their turn, and how many
            reminders could not be delivered.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - reminded_count
                - failed_count
                - skipped_out_of_turn
                - request
                properties:
                  message:
                    type: string
                    example: Reminder sent to 2 signers.
                  reminded_count:
                    type: integer
                    nullable: true
                    minimum: 0
                    description: Signers reminded; null when an outside provider sends
                      its own reminder.
                  failed_count:
                    type: integer
                    minimum: 0
                    description: Reminders that could not be delivered.
                  skipped_out_of_turn:
                    type: integer
                    minimum: 0
                    description: Signers later in the signing order, not reminded
                      until it is their turn.
                  request:
                    "$ref": "#/components/schemas/EsignatureRequestDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`app_disabled`, `forbidden` (the app''s access rules leave
            the caller out, or "Only the request creator or an administrator can perform
            this action") or `insufficient_permissions` ("Required scope: write:esignature").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`app_not_found` (the E-Signature app does not exist on this
            platform) or `not_found` ("Signature request not found" — no such request
            in this business, or one the caller may not see).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`cannot_remind` (only sent, viewed or partially signed requests
            can be reminded), `in_app_ceremony` (a policy signed in the app — manage
            it in Policy Hub) or `remind_failed` (the reason, e.g. "No one to remind
            — the next signer is waiting on someone earlier.").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/esignature/{id}/void":
    post:
      tags:
      - E-Signature
      summary: Void a signature request
      description: |
        Voids a request that is out for signature (`sent`, `viewed` or
        `partially_signed`): its signers can no longer sign it. The request's
        creator, their manager or an administrator may void it; a policy signed
        in the app is Policy Hub's record and is refused here (422
        `in_app_ceremony`).

        **Required Scopes:** `destructive:esignature`

        Delete-level (owner decision 2026-10-04, #6659): requires the
        `destructive:esignature` scope; `write:esignature` is not enough. A token
        without it gets `403 insufficient_permissions` ("Required scope:
        destructive:esignature"), checked after the E-Signature app gate. The
        request detail's `can_void` is false for such a token.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Signature request ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
                  description: Why the request was voided. Trimmed and cut to 500
                    characters; a non-string value is ignored.
      responses:
        '200':
          description: The request was voided.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - request
                properties:
                  message:
                    type: string
                    example: Request has been voided
                  request:
                    "$ref": "#/components/schemas/EsignatureRequestDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`app_disabled`, `forbidden` (the app''s access rules leave
            the caller out, or "Only the request creator or an administrator can perform
            this action") or `insufficient_permissions` ("Required scope: destructive:esignature").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`app_not_found` (the E-Signature app does not exist on this
            platform) or `not_found` ("Signature request not found" — no such request
            in this business, or one the caller may not see).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`cannot_void` (only sent, viewed or partially signed requests
            can be voided), `in_app_ceremony` (a policy signed in the app — manage
            it in Policy Hub) or `void_failed`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/digital-signage/screens":
    get:
      tags:
      - Digital Signage
      summary: List the business's signage screens
      description: |
        Every Digital Signage screen (display) in the current business, ordered
        by name, then id (a stable order, so paging never repeats or skips a
        screen). Filters combine (AND) and run before pagination, so
        `total_count` and `meta` count the filtered set.

        `status` is the runtime status a screen reports: `inactive` when an
        admin switched it off, otherwise `never_connected` (no heartbeat yet),
        `online` (heartbeat within the last 5 minutes) or `offline`. An
        unrecognised `status` value is ignored, not refused.

        Only app admins (a Digital Signage app admin, or a company admin or
        above) may list screens, and only while the tenant's "screen
        management" setting is on — the same rule as the web Screens tab.

        **Required Scopes:** `read:digital_signage` (also satisfied by `admin`
        or `read:own_digital_signage`). A token without it is refused with
        `403 insufficient_permissions` ("Required scope: read:digital_signage").
        A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: Runtime status. `online`, `offline` and `never_connected` match
          ACTIVE screens only; `inactive` matches screens an admin switched off. Any
          other value is ignored (no filter).
        schema:
          type: string
          enum:
          - online
          - offline
          - inactive
          - never_connected
      - name: online
        in: query
        required: false
        description: "`true` keeps screens whose last heartbeat is within the last
          5 minutes; any false-y value (`false`, `0`, an empty value) keeps the rest
          (never seen, or last seen longer ago). Unlike `status=online`, this ignores
          the screen's active flag. Omit for no filter."
        schema:
          type: boolean
      - name: location_id
        in: query
        required: false
        description: Only screens bound to this location (exact match).
        schema:
          type: integer
      - name: active_only
        in: query
        required: false
        description: "`true` keeps only active screens. Default: all screens."
        schema:
          type: boolean
          default: false
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Page size. Values outside 1–100 are clamped into that range.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of screens. Pagination is also in the `X-Total-Count`
            / `X-Total-Pages` / `X-Per-Page` / `X-Current-Page` headers. A page past
            the last answers an empty `items`.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/DigitalSignageScreen"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `app_disabled` — Digital Signage is not enabled for the business.
            - `app_access_denied` — the caller has no access to the Digital Signage app.
            - `insufficient_permissions` — the token lacks the scope ("Required scope: read:digital_signage").
            - `screen_management_disabled` — the tenant switched screen management off.
            - `screens_access_denied` — the caller is not a Digital Signage app admin (or company admin).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Digital Signage app is not configured
            on this deployment."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/digital-signage/media":
    get:
      tags:
      - Digital Signage
      summary: List the business's signage media assets
      description: |
        The Digital Signage media library of the current business — admin-pushed
        image and video slides in every review state (draft, pending, approved,
        rejected) unless filtered — ordered by `position`, then oldest first.
        Filters combine (AND) and run before pagination, so `total_count` and
        `meta` count the filtered set. An unrecognised `status` or `media_kind`
        value is ignored, not refused.

        Only media authors and reviewers may list it (a Digital Signage app
        admin, a company admin or above, an app contributor, or a holder of the
        signage-media approval capability), and only while the tenant's
        "custom media" setting is on — the same rule as the web Media tab.

        **Required Scopes:** `read:digital_signage` (also satisfied by `admin`
        or `read:own_digital_signage`). A token without it is refused with
        `403 insufficient_permissions` ("Required scope: read:digital_signage").
        A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: Review state (exact match). Any other value is ignored (no filter).
        schema:
          type: string
          enum:
          - draft
          - pending
          - approved
          - rejected
      - name: media_kind
        in: query
        required: false
        description: Media kind (exact match). Any other value is ignored (no filter).
        schema:
          type: string
          enum:
          - image
          - video
      - name: location_id
        in: query
        required: false
        description: Assets bound to this location PLUS assets shown at all locations
          (no location set).
        schema:
          type: integer
      - name: publishable
        in: query
        required: false
        description: "`true` keeps only assets a screen may show right now: approved
          AND inside their `active_from` / `active_until` window. (Not applied by
          this filter, so an asset it keeps may still play nowhere — among others:
          a recurring day-of-week / time-of-day restriction; a missing file — an asset
          whose `media_url` is null plays nothing; the sample rule — an asset whose
          `is_sample_data` is true plays only on screens whose `is_sample_data` is
          true; and whether any screen matches its location and zone tags or keeps
          it inside its Max items.)"
        schema:
          type: boolean
          default: false
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Page size. Values outside 1–100 are clamped into that range.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of media assets. Pagination is also in the `X-Total-Count`
            / `X-Total-Pages` / `X-Per-Page` / `X-Current-Page` headers. A page past
            the last answers an empty `items`.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/DigitalSignageMediaAsset"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `app_disabled` — Digital Signage is not enabled for the business.
            - `app_access_denied` — the caller has no access to the Digital Signage app.
            - `insufficient_permissions` — the token lacks the scope ("Required scope: read:digital_signage").
            - `media_disabled` — the tenant switched custom media off.
            - `media_access_denied` — the caller is neither a media author nor a media reviewer.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Digital Signage app is not configured
            on this deployment."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/live_assist/sessions":
    get:
      tags:
      - Live Assist
      summary: List Live Assist sessions
      description: |
        The Live Assist video sessions the caller may see in their business,
        newest first (`created_at` descending), paginated.

        Which sessions are listed is the same rule the web list applies:
        a caller who may view all sessions sees every session; anyone else sees
        the sessions they host or joined as a participant, open calls that rang
        them, live SOS calls their record admits them to (a Lone Worker alert's
        responders), and — for a manager — every open `call_for_help` /
        `show_me` session except Lone Worker SOS calls (listed to the alert's
        responders, above) and Remote support calls (a Service Desk call placed
        to a ticket's requester, which rings nobody else). `watch` sessions are
        listed only to callers who may observe feeds.

        Optional filters: `status` and `mode`. A value outside the listed enum
        is ignored (the filter is not applied), never a 400.

        **Required Scopes:** `read:live_assist`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:live_assist")
        before anything else is checked. The sign-in tokens hold it; a browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Sessions per page. Clamped to 1..100; a blank value means 25,
          and a non-numeric value reads as 0 and is clamped to 1.
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: Only sessions in this state. Any other value is ignored.
        schema:
          type: string
          enum:
          - pending
          - live
          - ended
      - name: mode
        in: query
        description: 'Only sessions of this kind, as `mode_label` names them: `remote_support`
          lists Remote support calls, and `call_for_help` / `show_me` leave them out.
          Any other value is ignored.'
        schema:
          type: string
          enum:
          - call_for_help
          - show_me
          - watch
          - huddle
          - remote_support
      responses:
        '200':
          description: One page of the sessions the caller may see
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/LiveAssistSession"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:live_assist` ("Required scope: read:live_assist").
            - `app_disabled` — Live Assist is not enabled for the business.
            - `app_forbidden` — Live Assist is not published to the caller, and they neither hold a seat on a session nor are rung by, or seated as a responder on, an open one.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Live Assist app is not configured on
            this install."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/live_assist/sessions/{id}":
    get:
      tags:
      - Live Assist
      summary: Get a Live Assist session
      description: |
        One Live Assist session the caller may see. The same rule as the list
        decides, plus the per-record answer the web room and details pages
        give for a session stapled to a gated record (a Lone Worker SOS call
        admits that alert's responders). A session the caller may not see
        answers `404 not_found`, exactly like a missing one.

        **Required Scopes:** `read:live_assist`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:live_assist")
        before anything else is checked. The sign-in tokens hold it; a browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Session id.
        schema:
          type: integer
      responses:
        '200':
          description: The session
          content:
            application/json:
              schema:
                type: object
                required:
                - session
                properties:
                  session:
                    "$ref": "#/components/schemas/LiveAssistSession"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:live_assist` ("Required scope: read:live_assist").
            - `app_disabled` — Live Assist is not enabled for the business.
            - `app_forbidden` — Live Assist is not published to the caller, and they neither hold a seat on a session nor are rung by, or seated as a responder on, an open one.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` — no session with this id in the business, or one the caller may not see ("Session not found").
            - `app_not_found` — the Live Assist app is not configured on this install.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/live_boards/boards":
    get:
      tags:
      - Live Boards
      summary: List boards
      description: |
        Boards visible to the caller.

        **Authoring tier** gets every board in the tenant. `status` selects
        `active` (default), `archived`, or `all`, and each row carries every
        active reading on the board.

        **Members** get only boards the audience gate admits them to — a board
        is included when AT LEAST ONE reading on it is readable — and each row
        carries only the readings they are admitted to. `visualizations_count`
        is that admitted count, never the board's true total, so the number a
        member is shown always matches the list beside it. `status` is ignored
        for members: an archived board renders nowhere.

        A member's list resolves its audience gate in Ruby and therefore scans
        a bounded number of boards. `meta.scan_truncated` reports whether that
        bound was reached — it is present on the member response only.

        Readings do NOT carry their data here (one source query per reading
        would make a page of boards expensive); use the detail endpoint.

        **Required Scopes:** `read:live_boards` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:live_boards")
        before anything else is checked. The sign-in tokens hold it; a token a
        person makes by hand does not (an authoring-tier list is every board). A
        browser session is asked no scope.
      parameters:
      - name: status
        in: query
        required: false
        description: Authoring tier only. One of `active` (default), `archived`, `all`.
        schema:
          type: string
          enum:
          - active
          - archived
          - all
      - name: q
        in: query
        required: false
        description: Case-insensitive search on the board's name or the name of a
          visualization on it. A member matches only visualizations they are admitted
          to.
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: Boards the caller may see
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/LiveBoard"
                    meta:
                      type: object
                      properties:
                        total_count:
                          type: integer
                        total_pages:
                          type: integer
                        current_page:
                          type: integer
                        per_page:
                          type: integer
                        has_next_page:
                          type: boolean
                        has_prev_page:
                          type: boolean
                        scan_truncated:
                          type: boolean
                          description: Member responses only. True when the audience
                            scan hit its bound, i.e. the tenant holds more boards
                            than were examined.
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: The token lacks `read:live_boards` (`insufficient_permissions`),
            or Live Boards is not enabled for the business (`feature_not_enabled`)
            or not published to users (`not_published`)
  "/live_boards/boards/{id}":
    get:
      tags:
      - Live Boards
      summary: Get a board with its current readings
      description: |
        The board plus every reading the caller is admitted to, each with its
        current data.

        Returns **404** — not 403 — for a member admitted to no reading on the
        board. Which of "this board is empty" and "you are not admitted to it"
        applies is itself information about a board the caller may not see. The
        authoring tier is exempt and may open any board.

        Each reading's `data` object is shaped by its visualization type
        (`ranked_list`, `stat`, `line`, `table`, `chart`, `movers`, `gauge`,
        `status`, `share`, `calendar`, `streak`, `pace`, `scorecard`); the
        common members are documented on `LiveBoardReadingData`. A reading whose
        source is unavailable returns `{ "error": "..." }` in place of its data
        rather than failing the whole board.

        **Required Scopes:** `read:live_boards` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:live_boards")
        before anything else is checked. The sign-in tokens hold it; a token a
        person makes by hand does not (an authoring-tier list is every board). A
        browser session is asked no scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The board and its readable readings
          content:
            application/json:
              schema:
                type: object
                properties:
                  board:
                    "$ref": "#/components/schemas/LiveBoard"
        '404':
          description: No such board in this business, or the caller is admitted to
            nothing on it
        '403':
          description: The token lacks `read:live_boards` (`insufficient_permissions`),
            or Live Boards is not enabled for the business (`feature_not_enabled`)
            or not published to users (`not_published`)
  "/live_boards/playlists":
    get:
      tags:
      - Live Boards
      summary: List rotations (playlists)
      description: |
        Named, ordered rotations of visualizations — what a break-room screen
        plays. Each item is one visualization (`visualization_ids`).

        **Authoring tier only** (contributor or admin); members get 403. A
        rotation is authoring material, matching the web, where the Playlists
        tab is contributor-gated.

        `rotation_url` and `embed_allowed_origins` are returned **to app admins
        only**. The rotation URL is an unauthenticated credential: anyone
        holding it reads the rotation with no sign-in, so every control over it
        is admin-tier on every surface. The raw token is never serialized on its
        own.

        **Required Scopes:** `read:live_boards` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:live_boards")
        before anything else is checked. The sign-in tokens hold it; a token a
        person makes by hand does not (an authoring-tier list is every board). A
        browser session is asked no scope.
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - active
          - archived
          - all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: Rotations in this business
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/LiveBoardPlaylist"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: The token lacks `read:live_boards` (`insufficient_permissions`),
            or the caller is not a Live Boards contributor or admin
  "/live_boards/playlists/{id}":
    get:
      tags:
      - Live Boards
      summary: Get a rotation with its visualizations
      description: |
        As the list endpoint, plus `boards` — the rotation's visualizations
        resolved in play order and re-checked against the audience gate (the
        key keeps its old name for existing clients). `visualization_ids` are
        frozen at authoring time, so a visualization that has since been
        archived or restricted is omitted from `boards` rather than named.

        **Required Scopes:** `read:live_boards` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: read:live_boards")
        before anything else is checked. The sign-in tokens hold it; a token a
        person makes by hand does not (an authoring-tier list is every board). A
        browser session is asked no scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The rotation
          content:
            application/json:
              schema:
                type: object
                properties:
                  playlist:
                    "$ref": "#/components/schemas/LiveBoardPlaylist"
        '404':
          description: No such rotation in this business
        '403':
          description: The token lacks `read:live_boards` (`insufficient_permissions`),
            or the caller is not a Live Boards contributor or admin
  "/lone_worker/alerts":
    get:
      tags:
      - Lone Worker
      summary: List alerts the caller raised or responds to
      description: |
        Alerts the caller raised, alerts on rosters the caller responds for,
        and alerts the caller acknowledged or resolved (read only, kept after
        they leave the roster), newest first. A company admin or Lone Worker app
        admin whose token carries the WIDE `read:lone_worker` scope sees every
        alert in the tenant; an `own_`-only token keeps them to the rest. A
        worker the app monitors but does not otherwise admit is refused this
        list (403 `app_not_accessible`); their own alerts are in
        `GET /lone_worker/safety_status` (`recent_alerts`).
      security:
      - BearerAuth: []
      parameters:
      - name: state
        in: query
        required: false
        description: '`active` (raised + notifying), `open` (every alert still on
          its ladder — raised, notifying or acknowledged) or one alert state. `open`
          EXCLUDES `exhausted` alerts — the ladder ran out and no outcome is recorded
          yet, so nobody has confirmed the worker is safe; ask for those with `state=exhausted`
          and never present an empty `open` list as "everyone is accounted for" without
          it. Any other value is a 400 `invalid_state` — an unrecognised filter never
          silently returns every alert.'
        schema:
          type: string
          enum:
          - active
          - open
          - raised
          - notifying
          - acknowledged
          - resolved
          - exhausted
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: One page of alerts. Pagination is also in the `X-Total-Count`
            / `X-Total-Pages` / `X-Per-Page` / `X-Current-Page` headers.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/LoneWorkerAlert"
                    meta:
                      type: object
                      properties:
                        total_count:
                          type: integer
                        total_pages:
                          type: integer
                        current_page:
                          type: integer
                        per_page:
                          type: integer
                        has_next_page:
                          type: boolean
                        has_prev_page:
                          type: boolean
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: "`invalid_state`."
        '401':
          description: Authentication required
        '403':
          description: "`insufficient_permissions`, `app_not_enabled` or `app_not_accessible`."
  "/lone_worker/alerts/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Lone Worker
      summary: One alert, with who resolved it and its escalation trail
      description: Readable by the alert's worker, a responder on its roster, a safety
        admin whose token carries the wide `read:lone_worker`, and the person who
        acknowledged or resolved it (read only — kept after they leave the roster).
        Anyone else gets 404, the same as for an id that does not exist. A paged responder
        or the worker outside the app's access groups is admitted to this one alert,
        as for acknowledge and resolve.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The alert
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert:
                    "$ref": "#/components/schemas/LoneWorkerAlertDetail"
                  unread_notification_count:
                    type: integer
                    nullable: true
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Authentication required
        '403':
          description: "`insufficient_permissions`, `app_not_enabled` or `app_not_accessible`."
        '404':
          description: "`not_found` — no such alert, or not one the caller may read."
  "/lone_worker/alerts/{id}/acknowledge":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Lone Worker
      summary: Acknowledge an alert (take ownership; holds the escalation climb)
      description: 'A responder on the alert''s roster, or a company admin or Lone
        Worker app admin whose token carries the wide `write:lone_worker`, may acknowledge.
        The worker who raised the alert may not acknowledge their own. Acknowledging
        stops the climb and stands the other paged responders down, but only for 60
        minutes: if no outcome is recorded (resolve) by then, the alert goes back
        to its tier and pages it again (never while Lone Worker is switched off).'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert:
                    "$ref": "#/components/schemas/LoneWorkerAlert"
                  unread_notification_count:
                    type: integer
                    nullable: true
        '401':
          description: Authentication required
        '403':
          description: "`forbidden` (not a responder, or the caller's own alert),
            `view_as` or `attestation_not_delegable` (acting as someone else), `insufficient_permissions`,
            `app_not_enabled` or `app_not_accessible`."
        '404':
          description: "`not_found`."
        '409':
          description: "`already_acknowledged` or `already_terminal` — someone else
            got there first, or the alert is closed."
        '500':
          description: "`lone_worker_ack_failed` — our failure; retry."
  "/lone_worker/alerts/{id}/release":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Lone Worker
      summary: Hand an acknowledgement back (undo a mistaken acknowledge)
      description: The responder who acknowledged the alert, or a company admin or
        Lone Worker app admin, puts it back on its responder ladder at the same tier.
        That tier's other responders are paged again (the releaser is not); when nobody
        else is on it, the alert climbs to the next tier at once, and when no later
        tier is left it is exhausted and the safety admins are alerted. The worker
        is told, and the audit trail records who released it.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Released — the alert's `state` is `notifying` again, or `exhausted`
            when nobody on this or a later tier was left to page (the safety admins
            are alerted).
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert:
                    "$ref": "#/components/schemas/LoneWorkerAlert"
                  unread_notification_count:
                    type: integer
                    nullable: true
        '401':
          description: Authentication required
        '403':
          description: "`forbidden` (not the acknowledger, nor a company admin or
            Lone Worker app admin whose token holds the wide `write:lone_worker` scope),
            `release_not_permitted` (the acknowledgement changed hands meanwhile),
            `view_as` or `attestation_not_delegable` (acting as someone else), `insufficient_permissions`,
            `app_not_enabled` or `app_not_accessible`."
        '404':
          description: "`not_found`."
        '409':
          description: "`not_acknowledged` — the alert is not acknowledged (already
            handed back, resolved or exhausted); `sample_alert` — a sample alert never
            pages anyone, so there is nothing to hand back."
        '500':
          description: "`lone_worker_release_failed` — our failure; retry."
  "/lone_worker/alerts/{id}/resolve":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Lone Worker
      summary: Resolve an alert
      description: A responder on the alert's roster, or a company admin or Lone Worker
        app admin whose token carries the wide `write:lone_worker`, may resolve with
        any outcome. The worker who raised the alert may only cancel it as `false_alarm`.
        An `incident` resolution requires a note, and opens a Safety Hub incident
        unless the tenant has turned that hand-off off (`incident_id` is then null).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - resolution
              properties:
                resolution:
                  type: string
                  enum:
                  - safe
                  - false_alarm
                  - incident
                note:
                  type: string
                  description: Required when `resolution` is `incident`. Stored up
                    to 5,000 characters.
      responses:
        '200':
          description: Resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert:
                    "$ref": "#/components/schemas/LoneWorkerAlert"
                  incident_id:
                    type: integer
                    nullable: true
                    description: The Safety Hub incident an `incident` resolution
                      created.
                  incident_handoff_failed:
                    type: boolean
                    description: True when an `incident` resolution tried to open
                      a Safety Hub incident and could not. The alert is still resolved;
                      `incident_id` is null and the incident must be reported in Safety
                      Hub by hand.
                  unread_notification_count:
                    type: integer
                    nullable: true
        '401':
          description: Authentication required
        '403':
          description: "`forbidden`, `view_as` or `attestation_not_delegable` (closing
            the alert or recording its outcome as someone else), `insufficient_permissions`,
            `app_not_enabled` or `app_not_accessible`."
        '404':
          description: "`not_found`."
        '409':
          description: "`already_resolved`."
        '422':
          description: "`invalid_resolution`, `note_required` or `invalid_note` (a
            note that is not a string)."
        '500':
          description: "`lone_worker_resolve_failed` — our failure; retry."
  "/lone_worker/safety_status":
    get:
      tags:
      - Lone Worker
      summary: The caller's own safety status
      description: 'Whether the caller is clocked in, their armed check-in (always
        returned, so the "I''m safe" action stays reachable; `check_in_due` says whether
        it is awaiting an answer now, `responders_alerted_at` when responders are
        paged if it is not answered, `past_due` whether that moment has passed), their
        in-progress alert, if any — an acknowledged alert is still in progress — and,
        when none is in progress, their own alert from the last 24 hours whose ladder
        ran out with no outcome recorded (`exhausted_alert`): never present the card
        as all clear while it is set. `recent_alerts` are the caller''s own five most
        recent alerts, unresolved first (`recent_alerts_total` is how many they have
        in all), so a client can reach any of them to close it as a false alarm.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Safety status
          content:
            application/json:
              schema:
                type: object
                properties:
                  on_shift:
                    type: boolean
                  check_ins:
                    type: string
                    enum:
                    - armed
                    - paused
                    - ended
                    - ending
                    - not_covered
                    - off_shift
                    description: 'Where the worker stands for timed check-ins: armed
                      (a check-in is armed), paused (the worker''s SOS or missed check-in
                      alert is open; check-ins restart once it closes), ended (clocked
                      in past a covered shift''s scheduled end, so check-ins have
                      stopped), ending (a covered shift ends within one grace window,
                      so no further check-in is armed), not_covered (clocked in, no
                      responder roster covers the shift), off_shift (not clocked in).'
                  check_ins_ended_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: The covered shift's scheduled end, when check_ins
                      is ended or ending.
                  armed_check_in:
                    allOf:
                    - "$ref": "#/components/schemas/LoneWorkerCheckIn"
                    - type: object
                      properties:
                        responders_alerted_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When responders are paged if this check-in
                            is still unanswered (due time plus grace) — the deadline
                            the check-in push states.
                        past_due:
                          type: boolean
                          description: True once `responders_alerted_at` has passed.
                            An answer now is recorded as a missed check-in and raises
                            the alert; the worker then closes it as a false alarm.
                    nullable: true
                  check_in_due:
                    type: boolean
                    description: 'True while the armed check-in awaits an answer.
                      Always false while `check_ins` is `paused` (the worker''s own
                      alert is open: nothing is prompted, and a lapse is recorded
                      on that alert).'
                  active_alert:
                    allOf:
                    - "$ref": "#/components/schemas/LoneWorkerAlert"
                    nullable: true
                  exhausted_alert:
                    allOf:
                    - "$ref": "#/components/schemas/LoneWorkerAlert"
                    - type: object
                      properties:
                        exhaustion_kind:
                          type: string
                          enum:
                          - abandoned
                          - handed_back
                          - lapsed
                          - unstaffed
                          - unanswered
                          description: 'Why no outcome is recorded: `unanswered` —
                            nobody acknowledged in time; `lapsed` — a responder acknowledged
                            but did not close it, and nobody acknowledged after it
                            was escalated again; `handed_back` — the responder who
                            acknowledged it stopped handling it (handed it back, or
                            was taken off the roster or left), and nobody on that
                            tier or above was left to page; `unstaffed` — there was
                            nobody to page; `abandoned` — acknowledged but never closed,
                            so it was closed automatically.'
                    nullable: true
                    description: Null while an alert is in progress, and for alerts
                      raised more than 24 hours ago or seeded as sample data. The
                      worker can close it as a false alarm (POST /lone_worker/alerts/{id}/resolve).
                  recent_alerts:
                    type: array
                    maxItems: 5
                    items:
                      "$ref": "#/components/schemas/LoneWorkerAlert"
                  recent_alerts_total:
                    type: integer
                    description: 'How many alerts are filed under the caller in all:
                      the ones they raised, and any an unassigned safety device filed
                      under them.'
                  capture_location_on_sos:
                    type: boolean
                    description: 'Whether #sos stores the location the client sends
                      (the tenant''s setting).'
                  unread_notification_count:
                    type: integer
                    nullable: true
        '401':
          description: Authentication required
        '403':
          description: "`insufficient_permissions`, `app_not_enabled` or `app_not_accessible`."
  "/lone_worker/sos":
    post:
      tags:
      - Lone Worker
      summary: Raise an SOS
      description: 'One live SOS per worker: when the caller already has one in progress
        it is returned with `already_active: true` and 200, and no second ladder starts.
        Location is stored only when the tenant''s "Capture Location When SOS Is Triggered"
        setting is on; unparseable or out-of-range coordinates are dropped, never
        rejected.'
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                lat:
                  type: number
                  minimum: -90
                  maximum: 90
                lng:
                  type: number
                  minimum: -180
                  maximum: 180
                accuracy:
                  type: number
                  minimum: 0
                  description: Location accuracy in metres, stored as a whole number;
                    a value above 100000 is dropped.
      responses:
        '200':
          description: The caller's SOS already in progress (`already_active` is true)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoneWorkerRaiseResult"
        '201':
          description: Raised, and responders are being paged (`reached_responders`
            true)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoneWorkerRaiseResult"
        '202':
          description: Raised, but NO responder could be reached (`reached_responders`
            false; `message` tells the worker to call emergency services). Never present
            this as "help is on the way".
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoneWorkerRaiseResult"
        '401':
          description: Authentication required
        '403':
          description: "`view_as` or `attestation_not_delegable` (raising an SOS as
            someone else), `insufficient_permissions`, `app_not_enabled` or `app_not_accessible`.
            While Lone Worker is switched off, `app_not_enabled` says the SOS was
            NOT sent and to call the local emergency number — or that the caller's
            own SOS is still open (`details.open_alert_id`) — and nothing is raised."
        '500':
          description: "`lone_worker_raise_failed` — our failure; retry (a retry never
            starts a second SOS)."
  "/lone_worker/floor_issue":
    post:
      tags:
      - Lone Worker
      summary: Report a floor issue (line jam)
      description: Rides the same roster escalation as an SOS, without location. The
        note is required and is read to responders, truncated to 280 characters.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note
              properties:
                note:
                  type: string
                  description: Stored and read to responders truncated to 280 characters;
                    a longer note is accepted.
      responses:
        '201':
          description: Raised, and responders are being paged
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoneWorkerRaiseResult"
        '202':
          description: Raised, but no responder could be reached (`reached_responders`
            false)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LoneWorkerRaiseResult"
        '401':
          description: Authentication required
        '403':
          description: "`view_as` or `attestation_not_delegable` (reporting as someone
            else), `insufficient_permissions`, `app_not_enabled` or `app_not_accessible`.
            While Lone Worker is switched off, `app_not_enabled` says the floor issue
            was NOT reported and to call the local emergency number if anyone is in
            danger, and nothing is raised."
        '422':
          description: "`note_required`, or `invalid_note` (a note that is not a string)."
        '500':
          description: "`lone_worker_raise_failed` — our failure; retry."
  "/lone_worker/check_in/answer":
    post:
      tags:
      - Lone Worker
      summary: Answer a check-in ("I'm safe")
      description: Answers the given check-in, or the caller's earliest armed one
        when `check_in_id` is omitted.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                check_in_id:
                  type: integer
      responses:
        '200':
          description: Answered. `check_in` is the check-in just answered (state `ok`),
            not the next one; read the next armed check-in from `GET /lone_worker/safety_status`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  check_in:
                    "$ref": "#/components/schemas/LoneWorkerCheckIn"
                  unread_notification_count:
                    type: integer
                    nullable: true
        '401':
          description: Authentication required
        '403':
          description: "`insufficient_permissions`, `app_not_enabled` or `app_not_accessible`."
        '409':
          description: "`check_in_missed` — the window already closed and a missed-check-in
            alert was raised (`details.alert_id`); the message tells the worker to
            close it as a false alarm if they are safe. With `details.folded: true`
            nothing new was raised — the miss was added to the worker's own open SOS
            or missed-check-in alert (`details.alert_id`), whose responders are already
            alerted; show the message and do NOT offer to close that alert as a false
            alarm. `check_in_missed_raise_failed` — the window closed long enough
            ago that the alert could not be raised and will not be retried; show the
            message (tell a supervisor now) and do not retry."
        '422':
          description: "`no_armed_check_in` — nothing is awaiting an answer; also
            when the check-in was already missed and its alert has since been closed
            (the message says nothing else was raised)."
        '500':
          description: "`lone_worker_check_in_answer_failed`, or `check_in_missed_raise_pending`
            — the window had closed and the missed-check-in alert could not be raised
            yet (the check-in is re-armed and retried automatically). Our failure;
            retry."
  "/surveys/surveys":
    get:
      tags:
      - Surveys
      summary: List surveys
      description: |
        Two lists behind one route, selected by `scope`.

        **Employee self-service (default, `scope` omitted).** Surveys the caller
        is assigned to, currently open, and has not yet completed — the set
        `Survey.available_for_user` produces minus the ones they have already
        answered. Ordered by `closes_at` ascending (nulls last), then newest
        first. Rows carry no `response_count`.

        **`scope=created`.** The authoring list: surveys the caller may manage
        (their own plus any explicitly shared with them; every survey in the
        tenant for an admin), in ALL statuses, newest first. Reachability is the
        desktop authoring gate verbatim — the tenant's "who can create and
        manage surveys" capability, OR an explicit collaborator grant. Bare
        authorship is deliberately NOT enough. Rows carry `response_count`.
        A create-capable user with no surveys yet gets an empty list, not a 403.

        Both branches accept `status`, `survey_type` and `search`.

        **Unusable parameters are REFUSED, never silently ignored** — an API
        caller has a machine-readable channel, so a filter this endpoint cannot
        honour is a `400` naming the accepted set rather than a `200` whose body
        quietly ignored it. `scope`, `status` and `survey_type` are allowlisted
        against their enums; `status`, `survey_type` and `search` must each be a
        single value (a list- or object-shaped value is a `400`, checked against
        the raw query string).

        `page` is clamped to a ceiling of 1,000,000 before it reaches the
        paginator, so an out-of-range page number cannot 500 the request.

        Use this to list my surveys, show surveys assigned to me, see open
        surveys, check pending surveys, or list the surveys I manage.

        **Required Scopes:** `read:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        description: Omit for the caller's own assigned-and-open surveys. `created`
          switches to the authoring list. `results` lists the live or closed surveys
          whose full results the caller may read (the web Results index's rule — survey
          administrators, a results or co-owner grant, or the people the organization
          names to view every survey's results); a caller with none of these gets
          a `403`. Any other value — including a different case, or a list-shaped
          value — is a 400 `invalid_scope`.
        schema:
          type: string
          enum:
          - created
          - results
      - name: status
        in: query
        required: false
        description: Narrow to one lifecycle status. Mostly useful with `scope=created`;
          the employee list is already open-surveys-only.
        schema:
          type: string
          enum:
          - draft
          - active
          - closed
          - archived
      - name: survey_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - engagement
          - pulse
          - custom
          - manager_feedback
      - name: search
        in: query
        required: false
        description: Case-insensitive match on the survey's name and description only
          (never the creator's name — `survey_json` returns no creator, so matching
          on one would let a caller probe user names through a field the response
          does not contain). Whitespace-tokenised; tokens are ANDed; at most the first
          12 words are used, and a longer search is truncated rather than refused.
        schema:
          type: string
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The surveys the caller may see for the requested scope
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SurveySummary"
                    meta:
                      "$ref": "#/components/schemas/SurveysPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: |
            An unusable query parameter. `error.code` is one of:

            * `invalid_scope` — `scope` is neither absent nor `created`, or is
              list-shaped. `error.details.accepted_values` names the set.
            * `invalid_status` / `invalid_survey_type` — the value is outside
              the enum. `error.details.accepted_values` names the set.
            * `invalid_status` / `invalid_survey_type` / `invalid_search` with
              `error.details.parameter` — that key arrived list- or
              object-shaped and must be a single value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to Surveys (app not
              published to users, or a visibility rule excludes them).
            * `forbidden` — `scope=created` and the caller neither holds the
              create-and-manage capability nor an explicit collaborator grant.
            * `insufficient_permissions` — the API token lacks `read:surveys`
              ("Required scope: read:surveys", #7183).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/shared_summaries":
    get:
      tags:
      - Surveys
      summary: List the "You said, we did" summaries shared with me
      description: |
        The "You said, we did" summaries the people who ran a survey shared with
        the caller, newest first — what the Surveys home (web and phone) shows
        under "You said, we did", and what Ask AI's
        `get_shared_survey_summaries` returns, in the same keys.

        **Who sees what** is the summary's own reader
        (`Surveys::PublishedSummary.recent_for`): a summary is listed only when
        it was SENT to the caller — their own receipt of it, written when it
        went out — never because they are in the survey's audience today. So
        someone who joined after it was shared does not see it. A survey made
        confidential since is listed to nobody. A full-results reader of a
        survey whose summary was not sent to them reads it on
        `GET /surveys/surveys/{id}/results` (`shared_summary`) instead.

        **The window and the cap** are the Surveys home's: summaries sent in
        the last 90 days, at most 5 — a summary is news. The list is not
        paginated; an empty `items` means none was sent to the caller in that
        window.

        Each summary is the organization's own words, never anything an
        answer said, so no anonymity floor applies.

        Use this to show me what changed after a survey, read the results
        summaries shared with me, or see "you said, we did".

        **Required Scopes:** `read:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The summaries shared with the caller, newest first (possibly
            none)
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  - total_count
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SurveySharedSummary"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to Surveys (app not
              published to users, or a visibility rule excludes them).
            * `insufficient_permissions` — the API token lacks `read:surveys`
              ("Required scope: read:surveys", #7183).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/{id}":
    get:
      tags:
      - Surveys
      summary: Get a survey with its questions
      description: |
        The survey plus its ordered question list — the payload a native client
        renders the "take this survey" screen from.

        **Visibility.** Returned only when the caller may open the definition
        (owner, collaborator, or admin) OR the survey is currently open and the
        caller is in its audience. A hidden (link-only) survey reached directly
        by id still resolves for someone assigned to it, so onboarding- and
        PM-embedded surveys work. Anything else is `404`, never `403` — which of
        "no such survey" and "not yours" applies is itself information about a
        survey the caller may not see.

        **Questions** are the caller-fillable input fields only, in builder
        order; instruction / section / header display blocks are excluded.

        **Management numbers are tied to the caller's results TIER for THIS
        survey, not to a blanket capability:**

        * `:all` / `:aggregate` — `response_count`, `target_audience_count` and
          `completion_rate` (the whole-survey figures, matching the web). At
          `:aggregate`, on an anonymous survey below its response floor,
          `response_count`, `completion_rate`, `responded_from_audience` and
          `responses_outside_audience` are `null`: a run's audience is a handful
          of named people, so the count says whether they answered (the web
          masks the same numbers).
        * `:team` — `response_count` ONLY, counting the caller's team (direct
          reports, or everyone below them with `reach=all`), and `null` when an anonymous survey's team count sits below
          the tenant's anonymity floor — or, on an anonymous survey that shares
          its results with employees, when fewer people than the floor answered
          outside the team (the whole-survey count minus the team's would say
          how many of them did). No `target_audience_count`, no
          `completion_rate` — the web renders no Target or Completion tile for
          this tier either.
        * `:none` — none of the three fields is present.

        **Required Scopes:** `read:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). An invited respondent's read asks it too. A token without it gets `403 insufficient_permissions`
        ("Required scope: read:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: reach
        in: query
        required: false
        description: "`:team` tier only — whose answers the team slice covers: `direct`
          (the caller's direct reports, the default — everyone below the caller when
          no direct report is left) or `all` (everyone below the caller in this business's
          reporting line). On a survey where fewer people below the direct reports
          answered than the anonymity floor, `all` falls back to the direct reports,
          so the two slices can never be subtracted to isolate a few people. Ignored
          at other tiers."
        schema:
          type: string
          enum:
          - direct
          - all
          default: direct
      responses:
        '200':
          description: The survey and its questions
          content:
            application/json:
              schema:
                type: object
                required:
                - survey
                properties:
                  survey:
                    "$ref": "#/components/schemas/SurveyDetail"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`feature_not_enabled` (Surveys is not enabled for this business),
            `forbidden` (the caller does not have access to the Surveys app), or `insufficient_permissions`
            (the API token lacks `read:surveys`, #7183)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business, or the caller
            may neither take nor manage it. Both cases answer the same way on purpose."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/{id}/respond":
    post:
      tags:
      - Surveys
      summary: Submit a response to a survey
      description: |
        Records the caller's answers. Re-checks the same guards the in-app
        submit path checks, then builds the submission the same way — including
        anonymity handling: `user_id` is always retained for completion
        tracking, `is_anonymous` hides identity on every display surface, and on
        an anonymous survey the stored metadata omits user agent and IP.

        Answers go in `submission_data`, keyed by each question's `field_name`
        (from the detail endpoint's `questions`). Keys the template does not
        define are dropped. A payload from which not one recognised key could be
        read is refused with `empty_submission` rather than saved as a blank
        response — an accepted blank response would permanently lock the
        respondent out and inflate everyone's completion rate. A respondent who
        legitimately leaves every OPTIONAL question blank still posts those
        keys, so `{"comments": ""}` is accepted.

        **Attachments are not supported on this endpoint and it says so** rather
        than silently discarding them: this controller runs no upload pipeline,
        so a survey with a required upload field is structurally un-completable
        here, and a multipart body carrying a file is refused. Complete those in
        the app or from the survey's own link.

        On success any in-progress draft for this template is cleared, after any
        files already attached to that draft are transferred to the new
        submission.

        On success this answers **200**, not 201 — the endpoint renders through
        `render_single`, so the body is the single `submission` key with no
        piggyback and no `Location` header.

        **Required Scopes:** `write:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). An invited respondent's answer asks it too. A token without it gets `403 insufficient_permissions`
        ("Required scope: write:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                submission_data:
                  type: object
                  description: |
                    Answers keyed by `field_name`. The permitted shape depends
                    on the question's `field_type`:

                    * an ARRAY for `checkbox`, `multiselect`, `multi_choice`,
                      `multiple_choice`, `gallery`, `file`, `image`, `video`,
                      `audio`, `lookup`;
                    * an OBJECT for `matrix`, `annotation`, `range`, and for a
                      Likert-configured `scale` (one whose configuration carries
                      both `scale_options` and `statements`);
                    * a SCALAR for everything else. A container value on a
                      `rating` / `number` / `slider` / plain `scale` question is
                      dropped by the permit rather than stored.

                    An uploaded file is always dropped and then reported — see
                    `upload_not_supported` below.
                  additionalProperties: true
              required:
              - submission_data
      responses:
        '200':
          description: The response was recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - submission
                properties:
                  submission:
                    "$ref": "#/components/schemas/SurveySubmission"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `forbidden` — the caller is not in this survey's audience.
            * `insufficient_permissions` — the API token lacks `write:surveys`
              ("Required scope: write:surveys", #7183).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`already_completed` — the caller has already responded and
            the survey's own template does not allow multiple submissions."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            Either a refusal with an `error` object, or model validation
            failures with an `errors` array. The refusal codes are:

            * `survey_not_active` — the survey is not `active`, or the current
              time is outside its `opens_at`/`closes_at` window.
            * `no_questions` — the survey has no fillable questions.
            * `upload_not_supported` — the survey has a required upload field
              that this endpoint structurally cannot satisfy
              (`error.details.required_upload_field_names`), or the request
              carried an uploaded file
              (`error.details.rejected_upload_field_names`). Nothing was saved
              in either case.
            * `empty_submission` — no recognised answer key was found.
              `error.details.expected_field_names` lists the keys this template
              reads, in question order.
            * `conversation_survey` — the survey is answered in a conversation
              with the AI interviewer (`response_mode: conversation`), never as
              form answers. `error.details.take_url` is the take page, which
              starts or resumes the conversation. Nothing was saved.

            A required question left blank is a `validation_error` in `errors`:
            its `field` is `base`, its `message` names the question by its label,
            and its `field_name` is the question's key, so a client can mark the
            question it left blank. Nothing was saved.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            description: Field name with validation error
                          message:
                            type: string
                            description: Validation error message
                          code:
                            type: string
                            description: Always `validation_error`
                          field_name:
                            type: string
                            description: |
                              Present only when an error on `base` is about one named field — a
                              survey or form question left blank (`"<label> is required"`). The
                              question's key, as sent in `submission_data`.
  "/surveys/surveys/{id}/results":
    get:
      tags:
      - Surveys
      summary: Get anonymity-safe results for a survey
      description: |
        Aggregate results only. Raw per-respondent rows and respondent identity
        are never returned by this endpoint, at any tier.

        **Who sees what** is `Surveys::AccessPolicy#results_scope` (see the file
        header). `:all` and `:aggregate` read the org-wide aggregate and report
        `scope: "full"`; `:team` reads a slice narrowed to the caller's team —
        direct reports, or everyone below them with `reach=all` — and reports
        `scope: "team"`; `:none` is a `403`.
        Confidential surveys and the per-survey manager opt-in are honoured by
        the policy itself.

        **A manager on a survey that shares its results with employees** reads
        the aggregate by default — exactly what the people they manage read —
        and their team's slice with `scope=team`, the web and phone pages'
        "All responses" / "Your team". `team_view_available: true` says the
        caller may ask for it. That team slice is also compared with the whole
        survey: a numeric or choice question whose answers differ from the whole
        survey's by fewer people than the floor comes back
        `insufficient_data: true`, so subtracting the two never isolates a few
        people outside the team. And on an anonymous survey, a team slice that
        fewer people than the floor answered OUTSIDE of (none included) comes
        back like one below the floor — `response_count: null`,
        `anonymity_protected: true`, `question_summaries: []` — but with
        `threshold_met: true`: its participation would otherwise say how many of
        those few people answered.

        **The anonymity floor** is the tenant's `minimum_response_threshold`,
        resolved through the app's single resolver, which applies a platform
        floor of 5 that a tenant may raise but not lower. It suppresses in three
        places:

        1. On any survey whose response count (for this scope) is below the
           threshold — anonymous or not, as the web and phone results pages
           hold them back — no aggregates at all are returned:
           `anonymity_protected: true`, `threshold_met: false`, and
           `question_summaries: []`.
        2. On an ANONYMOUS survey at the `:team` tier below the threshold, the
           participation count itself is masked to `null` — a manager with one
           direct report would otherwise learn whether that named person
           answered.
        3. Per question: a numeric or choice question with fewer answers than
           the threshold returns its count and `insufficient_data: true` in
           place of the distribution. Free-text questions return a count of
           non-blank answers and never the answers themselves, at any volume;
           on an ANONYMOUS survey a free-text question with fewer answers than
           the threshold is marked `insufficient_data: true` as well.

        Non-anonymous surveys are not suppressed at the survey level (1 and 2 do
        not apply), but per-question suppression (3) still does. These are the
        same rules the web and phone results pages apply
        (`Surveys::ResultsSummary`).

        This endpoint is not paginated: the whole result set for the caller's
        scope is rolled up in one pass.

        **Required Scopes:** `read:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: reach
        in: query
        required: false
        description: "`:team` tier only — whose answers the team slice covers: `direct`
          (the caller's direct reports, the default — everyone below the caller when
          no direct report is left) or `all` (everyone below the caller in this business's
          reporting line). On a survey where fewer people below the direct reports
          answered than the anonymity floor, `all` falls back to the direct reports,
          so the two slices can never be subtracted to isolate a few people. Ignored
          at other tiers."
        schema:
          type: string
          enum:
          - direct
          - all
          default: direct
      - name: scope
        in: query
        required: false
        description: 'Omit for the results the caller''s tier reads by default. `team`
          asks for the caller''s team slice: a manager''s only view on a survey that
          does not share its results with employees, and an extra view beside the
          whole-survey aggregate on one that does (`team_view_available`). A caller
          with no team slice of this survey gets a `403`; any other value — including
          a list-shaped one — is a 400 `invalid_scope`.'
        schema:
          type: string
          enum:
          - team
      responses:
        '200':
          description: The aggregate results the caller's tier permits
          content:
            application/json:
              schema:
                type: object
                required:
                - results
                properties:
                  results:
                    "$ref": "#/components/schemas/SurveyResults"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '400':
          description: "`invalid_scope` — `scope` is neither absent nor `team`, or
            is list-shaped. `error.details.accepted_values` names the set."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `forbidden` — the caller's results tier for this survey is
              `:none`.
            * `forbidden` — `scope=team` was asked for and the caller has no
              team slice of this survey.
            * `insufficient_permissions` — the API token lacks `read:surveys`
              ("Required scope: read:surveys", #7183).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/surveys/{id}/remind":
    post:
      tags:
      - Surveys
      summary: Send a reminder to employees who have not responded
      description: |
        Queues `SurveyBulkDeliveryJob` in reminder mode. The job recomputes the
        non-responder set itself, so nothing is passed in and no recipient list
        is accepted.

        **Four gates run before anything is queued.** The caller must hold the
        survey's lifecycle tier (owner, co-owner, or admin). The survey must be
        active. A survey answered by conversation with the AI interviewer is
        refused while new conversations can't start — conversation mode is off
        for the organization, or its AI usage is blocked — exactly as the web's
        Send Reminders refuses it. And when the survey targets the ENTIRE company, the tenant's
        `send_to_entire_company` capability must also admit the caller — the
        same ceiling the web and the agent enforce; a department- or
        group-targeted reminder is unaffected by it.

        **Then three refusals establish there is something to send**, computed
        exactly as the web twin and the Surveys agent compute them, so the doors
        cannot disagree about who counts as a recipient: an empty target
        audience, an audience where everyone has already responded, and a
        reminder none of whose non-responders can open the Surveys app.

        A reminder reaches each non-responder who can open the Surveys app in
        their Inbox (an actionable item), and by email when they have an address
        on file — people without an email address are reached. Each reminder
        files a fresh "Reminder:" Inbox item and retires the invitation item the
        person held ("A newer reminder about this survey replaced this one."), so
        a person holds one open item for the survey.

        **Required Scopes:** `write:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: write:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '202':
          description: Reminder delivery was queued
          content:
            application/json:
              schema:
                type: object
                required:
                - accepted
                - survey_id
                - recipient_count
                - emailed_count
                - already_in_inbox_count
                - message
                properties:
                  accepted:
                    type: boolean
                    example: true
                  survey_id:
                    type: integer
                  recipient_count:
                    type: integer
                    description: Non-responders the reminder reaches — each gets a
                      fresh Inbox item, and an email when they have an address.
                    example: 12
                  emailed_count:
                    type: integer
                    description: The part of `recipient_count` with an email address
                      on file — the number that will be mailed.
                    example: 3
                  already_in_inbox_count:
                    type: integer
                    description: Deprecated — always 0. It counted non-responders
                      a reminder gave nothing new; a reminder now files a fresh Inbox
                      item for everyone, so there are none. Kept so existing clients
                      parse.
                    deprecated: true
                    example: 0
                  message:
                    type: string
                    example: Reminder delivery has been queued for 12 employees who
                      haven't responded yet — 3 by email and 9 in Notifications.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `forbidden` — the caller does not hold this survey's lifecycle
              tier (owner, co-owner, or admin).
            * `forbidden` — the survey targets the entire company and the
              tenant limits who may send company-wide.
            * `insufficient_permissions` — the API token lacks `write:surveys`
              ("Required scope: write:surveys", #7183).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such survey in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: |
            Nothing was queued. `error.code` is one of:

            * `survey_not_active` — reminders are only sent for an active
              survey.
            * `conversation_unavailable` — the survey is answered by
              conversation with the AI interviewer, and new conversations
              can't start (conversation mode off, or the organization's AI
              usage blocked); `error.message` says which, in the web's words.
            * `empty_audience` — no employees are in the target audience.
            * `all_responded` — every targeted employee has already responded.
            * `no_reachable_recipients` — none of the non-responders can open
              the Surveys app.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/surveys/feedback":
    post:
      tags:
      - Surveys
      summary: Submit anonymous feedback
      description: |
        The always-on anonymous feedback channel — the "speak up" box that is
        separate from any particular survey. The submitter's identity is not
        recorded on the row; what comes back is a one-way `claim_code` they keep
        in order to look up the status of their own submission later.

        Available only while the tenant has the anonymous feedback channel
        enabled (`anonymous_feedback_enabled`, on by default). Submitting also
        notifies the tenant's feedback triagers, matching the desktop path.

        Use this to submit anonymous feedback, report a concern anonymously,
        raise an issue without giving my name, or speak up.

        **Required Scopes:** `write:surveys` (#7183, owner rule 2026-10-06 "every
        api/v1 door asks a scope"; the pair Ask AI and MCP ask for the Surveys
        agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: write:surveys") before anything else is checked. The sign-in
        tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feedback
              properties:
                feedback:
                  type: object
                  description: Must be an object. A missing `feedback` key, or one
                    whose value is a scalar or a list, is a 400 `parameter_missing`.
                    Only `topic` and `message` are read; anything else is dropped.
                  required:
                  - message
                  properties:
                    topic:
                      type: string
                      enum:
                      - workload
                      - safety
                      - leadership
                      - culture
                      - compensation
                      - communication
                      - other
                      default: other
                      description: Omitting the key defaults it to `other`. An EMPTY
                        STRING is not defaulted and fails validation — send the key
                        absent rather than blank.
                    message:
                      type: string
                      minLength: 10
                      maxLength: 12000
      responses:
        '201':
          description: The feedback was recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - feedback
                properties:
                  feedback:
                    "$ref": "#/components/schemas/SurveyAnonymousFeedback"
        '400':
          description: "`parameter_missing` — `feedback` was absent, or present but
            not an object."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: |
            * `feature_not_enabled` — Surveys is not enabled for this business.
            * `forbidden` — the caller does not have access to the Surveys app.
            * `feature_not_enabled` — the anonymous feedback channel is turned
              off for this business.
            * `insufficient_permissions` — the API token lacks `write:surveys`
              ("Required scope: write:surveys", #7183).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/ai_interviews":
    get:
      tags:
      - AI Interviews
      summary: The caller's conversations
      description: |
        The caller's open conversations, and the ones they sent in the last 30
        days, newest activity first. Named conversations only; never a
        rehearsal ("Try it" belongs to the builder that started it), and never
        one whose app no longer runs conversations.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: Rows per page, 1–100 (default 20).
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The caller's conversations
          content:
            application/json:
              schema:
                type: object
                properties:
                  ai_interviews:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AiInterviewSummary"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` — the token lacks read:ai_interviews."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    post:
      tags:
      - AI Interviews
      summary: Start a conversation, or resume the one open
      description: |
        Starts the caller's conversation on an app's record — or resumes the
        one they already have open on it — through the app's own door, the
        same one its web page asks. `context_type` is a record type an app
        runs conversations on (an unknown one answers the list). 201 when it
        started — a new conversation opens on `screen: "opening"` until POST
        …/begin; 200 when an open one was resumed — it opens where it stands
        (its `screen`: the conversation, the review…).

        Refusals: `conversation_unavailable` (the app's door is shut — the
        survey closed, the exit interview was completed, the shift has
        feedback), `already_participated`, `ai_blocked` (the organization's AI
        is blocked, so a NEW conversation can't start; an open one is still
        resumed), `anonymous_on_web` (the conversation would not record who
        the person is, so it is held on the web: `details.take_url` is the
        app's own page for it, when the app names one), `start_failed`,
        `unknown_context_type`. A record that is not in this tenant answers
        `conversation_unavailable`, the same as one the caller may not take, so
        the API never confirms which records exist.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - context_type
              - context_id
              properties:
                context_type:
                  type: string
                  description: The record type, e.g. `Surveys::Survey`, `RecruitingOffboardingPlan`,
                    `AttendanceRecord`.
                  example: Surveys::Survey
                context_id:
                  type: integer
                  example: 812
      responses:
        '200':
          description: The conversation already open, resumed
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '201':
          description: A new conversation, on its opening screen
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '403':
          description: "`insufficient_permissions` — the token lacks write:ai_interviews;
            `private_conversation` — an impersonation token, or a session viewing
            as the person (see AiInterviewRefusal)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`already_participated` — the person already took part."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`conversation_unavailable`, `ai_blocked`, `anonymous_on_web`
            (with `details.take_url`), `start_failed` or `unknown_context_type` (with
            `details.context_types`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                anonymous_on_web:
                  value:
                    error:
                      code: anonymous_on_web
                      message: This conversation doesn't record who you are, so it
                        is held on the web, not in this app. Open it in a browser.
                      details:
                        take_url: https://acme.workforce.mangoapps.com/apps/surveys/take/812
  "/ai_interviews/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    get:
      tags:
      - AI Interviews
      summary: One conversation, as its page shows it
      description: |
        The screen (`opening`, `conversation`, `coding` while the review is
        drafted — poll this —, `review`, `done`, or `closed` when the app's own
        door shut on an unsent conversation, which may still be deleted), the
        transcript, topic progress, what the composer offers now and, on the
        review, what will be recorded. A read changes nothing, so it may be
        polled: a returning person's sitting opens with their next turn.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The conversation
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '403':
          description: "`insufficient_permissions` — the token lacks read:ai_interviews;
            `private_conversation` — an impersonation token, or a session viewing
            as the person (see AiInterviewRefusal)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - AI Interviews
      summary: End — delete the conversation and everything in it
      description: Before it is sent only; a sent conversation is the app's record
        (`already_sent`). A legal hold keeps it (`not_deleted`).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
                  id:
                    type: integer
                  return_url:
                    type: string
                    nullable: true
                    description: The app's own page the conversation was for.
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/begin":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: Start (the consent), or start the next topic
      description: |
        "Start" on the opening screen: the person agrees to what the opening
        said (`opening`, `about`), and the interviewer asks the first question.
        Between the sittings of a conversation held one topic per sitting
        (`composer.between_sittings`), it starts the next topic.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/turn":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: A typed answer
      description: |
        The person's words are saved before the interviewer is asked. When the
        interviewer can't answer (`reply_failed`, 502) the words are kept and
        `composer.awaiting_reply` is true: answer with POST …/retry, never by
        sending the same turn again.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - said
              properties:
                said:
                  type: string
                  maxLength: 2000
                  description: What the person wrote.
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '502':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/choose":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: A tapped answer to the question being asked
      description: Recorded with no model call. Only while `composer.item` is that
        measure (`moved_on` otherwise).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_key
              - said
              properties:
                item_key:
                  type: string
                  description: "`composer.item.key`."
                said:
                  description: One of `composer.item.options[].value` — a list of
                    them when `multiple`.
                  oneOf:
                  - type: string
                  - type: integer
                  - type: array
                    items:
                      type: string
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/skip":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: Skip the question being asked
      description: Never re-asked, no reason needed. A question the app needs answered
        has `can_skip` false and is refused (`needs_answer`). Send `item_key` (`composer.item.key`)
        to skip only the question you saw — once the conversation has moved on it
        is refused (`moved_on`) rather than skipping the next one.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                item_key:
                  type: string
                  description: "`composer.item.key`, the question being skipped."
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/retry":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: Try again after the interviewer didn't answer
      description: Answers the saved words (`composer.awaiting_reply`).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '502':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/speak":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: Hold to talk — one recorded clip as an answer
      description: |
        Offered while `composer.transports` includes `recording`. The clip is
        transcribed and answered as a turn; it is never attached or kept. Up to
        20 MB, sent as a FILE in the multipart `audio` field — not as text
        (base64 is refused). Refusals: `audio_missing` (no file in `audio`;
        nothing was transcribed), `no_speech` (the clip held no words),
        `clip_too_large` (413), `voice_unavailable` (503),
        `recording_not_offered`. Live voice is not offered through the API.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - audio
              properties:
                audio:
                  type: string
                  format: binary
                  description: The clip (audio/webm, audio/mp4, audio/mpeg, audio/wav
                    …).
                duration_ms:
                  type: integer
                  description: How long the person spoke
                  as the recorder measured it.:
      responses:
        '200':
          description: The conversation after the spoken answer
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/AiInterviewResponse"
                - type: object
                  properties:
                    transcript:
                      type: string
                      description: What was heard.
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '413':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '502':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '503':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/review":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    patch:
      tags:
      - AI Interviews
      summary: One change at "Here's what we'll record"
      description: |
        While `screen` is `review`. One of: a verdict on an answer
        (`answer_id` + `verdict`: `confirm` — Looks right, `edit` — their own
        words in `said`, a string, replace the summary, `remove`, or `value` —
        a measure's answer changed to `said`); a required question the
        conversation left unanswered (`missing_item` + `said`: a string for an
        open question, one of its options — a list of them when `multiple` —
        for a measure); the quote choice (`quote_consent`, one of
        `review.quote_choices`; ignored when that list is empty — the app
        never quotes anyone); "Did this capture what you think?"
        (`felt_captured`, a whole number 1–7, or null to clear). A request
        naming none of these is refused (`nothing_to_change`), as is `said`
        that is not a string where words are asked for (`write_something`,
        `write_an_answer`) and a `felt_captured` off the scale
        (`felt_captured`). Each part is applied on its own: a `quote_consent`
        or `felt_captured` sent beside a refused verdict is still kept, and
        the first refusal is the answer.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                answer_id:
                  type: integer
                verdict:
                  type: string
                  enum:
                  - confirm
                  - edit
                  - remove
                  - value
                missing_item:
                  type: string
                said:
                  oneOf:
                  - type: string
                  - type: integer
                  - type: array
                    items:
                      type: string
                quote_consent:
                  type: string
                  enum:
                  - none
                  - deidentified
                  - with_name
                  description: One of `review.quote_choices`; ignored when that list
                    is empty.
                felt_captured:
                  type: integer
                  minimum: 1
                  maximum: 7
                  nullable: true
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/submit":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    post:
      tags:
      - AI Interviews
      summary: Send
      description: |
        Sends what the review shows; untouched answers count as Looks right.
        `quote_consent` and `felt_captured` may ride along, as PATCH …/review
        takes them. Refusals: `missing_required` (422, with
        `details.missing`: the item keys still needing an answer),
        `already_participated`, `closed` (409), `invalid` (422 — what was sent
        could not be saved), `not_sent` (422 — refused with no code of its
        own), or the app's own refusal under its code (`already_given`, 409,
        when the shift already has feedback).
      security:
      - BearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                quote_consent:
                  type: string
                  enum:
                  - none
                  - deidentified
                  - with_name
                  description: One of `review.quote_choices`; ignored when that list
                    is empty.
                felt_captured:
                  type: integer
                  minimum: 1
                  maximum: 7
                  nullable: true
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/ai_interviews/{id}/language":
    parameters:
    - name: id
      in: path
      required: true
      description: The conversation's id.
      schema:
        type: integer
    patch:
      tags:
      - AI Interviews
      summary: The conversation's language — the interviewer follows
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - language
              properties:
                language:
                  type: string
                  example: es
      responses:
        '200':
          description: The conversation after the change — what GET /ai_interviews/{id}
            answers
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewResponse"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
        '422':
          description: Refused — the codes are AiInterviewRefusal's
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AiInterviewRefusal"
  "/workspace/templates":
    get:
      tags:
      - Workspace
      summary: List workspace templates
      description: |
        The workspace templates available to the caller: the system-provided
        set plus any the tenant has authored. App-wide, so this is the one
        endpoint in the namespace with no workspace and no membership gate.

        Each row carries its full `structure` blueprint (the sections, task
        lists and seed content a workspace created from it receives), so keep
        `per_page` modest.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Templates available to the caller
          content:
            application/json:
              schema:
                type: object
                required:
                - templates
                - total_count
                - meta
                properties:
                  templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceTemplate"
                  total_count:
                    type: integer
                    description: Templates matching the request, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/templates/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The template's numeric id OR its slug.
      schema:
        type: string
      example: customer-onboarding
    get:
      tags:
      - Workspace
      summary: One template
      description: |
        One workspace template, with its full `structure` blueprint.
        Addressable by numeric id OR by the `slug` the list hands out — the
        same reference `template` on `POST /workspace/workspaces` accepts.

        A slug is looked up among the system templates first, then the
        tenant's own, and only then is an all-digit value read as an id. A
        tenant template may reuse a system template's slug; the system one
        wins that lookup, so use the numeric `id` to reach the tenant's copy.

        The response adds `template.plan`: every to-do and milestone of a
        dated template with its day offsets from the anchor date and, when
        `anchor_date` is given, its dates — exactly what `POST
        /workspace/workspaces` with the same `anchor_date` writes.
      security:
      - BearerAuth: []
      parameters:
      - name: anchor_date
        in: query
        required: false
        description: The anchor date (YYYY-MM-DD) to date the plan preview from —
          go-live, opening day, the deadline. An unreadable date is `422`.
        schema:
          type: string
          format: date
        example: '2027-03-01'
      responses:
        '200':
          description: The template
          content:
            application/json:
              schema:
                type: object
                required:
                - template
                properties:
                  template:
                    allOf:
                    - "$ref": "#/components/schemas/WorkspaceTemplate"
                    - type: object
                      properties:
                        plan:
                          "$ref": "#/components/schemas/WorkspaceTemplatePlan"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No active template with that id or slug is visible to this
            business (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/portfolio":
    get:
      tags:
      - Workspace
      summary: The portfolio — every project the caller may see, with its health
      description: |
        The portfolio (employee-experience PM-4): the projects the caller may
        see, with the owner's health beside what the data shows, filtered and
        paged exactly as the web and mobile Portfolio pages are
        (Workspaces::PortfolioQuery).

        WHO SEES WHAT (`reach` in the response): a Workspace app admin sees
        every workspace (`tenant`); someone who may open Workspace Reports sees
        every workspace that is not private plus their own (`reportable`);
        everyone else sees only the workspaces they can open (`own`). A row the
        caller may see but not open has `can_open: false`.

        `health_counts` and `signal_counts` cover EVERY matching project, not
        just this page: `health_counts` over every filter but `health`,
        `signal_counts` over every filter but `signal` — each is exactly the
        total the same request with that filter would return. A signal whose
        section (to-dos, check-ins) is switched off business-wide has no key.

        Advanced planning: below Platform Standard the endpoint answers `402`
        with error code `upgrade_required`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`.
        schema:
          type: integer
          minimum: 1
      - name: q
        in: query
        required: false
        description: Words in the name or description, the owner's name (or email,
          where the organization's directory shows email to the caller), or `#123`
          for workspace 123.
        schema:
          type: string
          maxLength: 120
      - name: health
        in: query
        required: false
        schema:
          type: string
          enum:
          - on_track
          - at_risk
          - off_track
          - not_set
      - name: signal
        in: query
        required: false
        description: Only projects where the data shows this.
        schema:
          type: string
          enum:
          - missed_milestones
          - high_risks
          - overdue_tasks
          - check_in_due
          - open_risks
      - name: kind
        in: query
        required: false
        schema:
          type: string
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - active
          - archived
          - all
          default: active
      - name: department_id
        in: query
        required: false
        schema:
          type: integer
      - name: location_id
        in: query
        required: false
        description: The location and every location beneath it.
        schema:
          type: integer
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - updated
          - name
          - ends_on
          - health
          default: updated
      responses:
        '200':
          description: The caller's portfolio
          content:
            application/json:
              schema:
                type: object
                required:
                - workspaces
                - total_count
                - meta
                - reach
                - filters
                - health_counts
                - signal_counts
                properties:
                  workspaces:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        slug:
                          type: string
                        status:
                          type: string
                          enum:
                          - active
                          - archived
                        kind:
                          type: string
                          nullable: true
                        health:
                          type: string
                          enum:
                          - on_track
                          - at_risk
                          - off_track
                          - not_set
                        health_signals:
                          type: object
                          description: overdue_tasks, check_in_due, missed_milestones,
                            open_risks, high_risks and a tone.
                        department:
                          type: string
                          nullable: true
                        owner:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                        sponsor:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                        starts_on:
                          type: string
                          format: date
                          nullable: true
                        ends_on:
                          type: string
                          format: date
                          nullable: true
                        updated_at:
                          type: string
                          format: date-time
                        can_open:
                          type: boolean
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
                  reach:
                    type: string
                    enum:
                    - tenant
                    - reportable
                    - own
                  filters:
                    type: object
                    description: The filters applied
                    normalised (defaults left out).:
                  health_counts:
                    type: object
                    additionalProperties:
                      type: integer
                  signal_counts:
                    type: object
                    additionalProperties:
                      type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '402':
          description: The organization's plan does not include advanced planning
            (error code `upgrade_required`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workload":
    get:
      tags:
      - Workspace
      summary: Workload — people × weeks, each person's to-dos against their hours
      description: |
        Workload (employee-experience PM-7): for each person the caller may see,
        their open Workspace to-dos week by week against the hours they have,
        paged and ordered exactly as the web and mobile Workload pages are
        (Workspaces::WorkloadQuery).

        HOURS: a to-do's estimate spread evenly over the weekdays of its span
        (start to due day), from today on. CAPACITY: the person's contract for
        that week of its pay-period cycle, a manager's target on a schedule
        covering the week, the role's expected hours, or the standard 40 —
        less approved leave, and in the current week only the weekdays left.
        `capacity_source` says which. A to-do with no estimate is counted in
        `todo_count` and `unestimated_count`, never timed.

        WHO SEES WHOM (`reach`): the portfolio's rule — a Workspace app admin
        everyone working in a project (`tenant`), a report viewer the people in
        every project that is not private plus their own (`reportable`),
        everyone else the people in their own projects (`own`) — plus, always,
        the people the caller manages. A person's load counts every open
        to-do they hold; this endpoint never returns a to-do's title.

        To move work, `PATCH /workspace/workspaces/{id}/tasks/{task_id}` with
        `assignee_id`.

        Advanced planning: below Platform Standard the endpoint answers `402`
        with error code `upgrade_required`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: People per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`.
        schema:
          type: integer
          minimum: 1
      - name: q
        in: query
        required: false
        description: Words in the person's name (or email, where the organization's
          directory shows email to the caller).
        schema:
          type: string
          maxLength: 120
      - name: weeks
        in: query
        required: false
        schema:
          type: integer
          enum:
          - 4
          - 6
          - 8
          - 12
          default: 6
      - name: start
        in: query
        required: false
        description: A date in the first week shown (never before this week). Default
          this week.
        schema:
          type: string
          format: date
      - name: sort
        in: query
        required: false
        description: "`load` — most estimated hours in the window first; `name` —
          alphabetical."
        schema:
          type: string
          enum:
          - load
          - name
          default: load
      responses:
        '200':
          description: The caller's workload
          content:
            application/json:
              schema:
                type: object
                required:
                - people
                - weeks
                - total_count
                - meta
                - reach
                - filters
                properties:
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        open_todos:
                          type: integer
                          description: Every open task the person holds — every project
                            and the Tasks app.
                        past_due_count:
                          type: integer
                          description: Open Workspace to-dos whose last day has passed.
                        undated_count:
                          type: integer
                          description: Open Workspace to-dos with no date.
                        weeks:
                          type: array
                          items:
                            type: object
                            properties:
                              starts_on:
                                type: string
                                format: date
                              load_hours:
                                type: number
                              capacity_hours:
                                type: number
                              capacity_source:
                                type: string
                                enum:
                                - contract
                                - schedule
                                - role_default
                                - default
                              utilisation:
                                type: number
                                nullable: true
                                description: load ÷ capacity; null when the week has
                                  no hours.
                              band:
                                type: string
                                enum:
                                - free
                                - light
                                - balanced
                                - stretched
                                - over
                              todo_count:
                                type: integer
                              unestimated_count:
                                type: integer
                              leave_days:
                                type: number
                                description: Weekdays of approved leave (a half day
                                  is 0.5).
                  weeks:
                    type: array
                    items:
                      type: string
                      format: date
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
                  reach:
                    type: string
                    enum:
                    - tenant
                    - reportable
                    - own
                  filters:
                    type: object
                    description: The filters applied
                    normalised (defaults left out).:
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '402':
          description: The organization's plan does not include advanced planning
            (error code `upgrade_required`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, or Workspace to-dos are switched off for the business ("Tasks
            are disabled for this business.") — error code `forbidden`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces":
    get:
      tags:
      - Workspace
      summary: List the caller's workspaces
      description: |
        The workspaces the CALLER belongs to — an explicit membership or the
        workspace's notification recipient group.

        This is membership-scoped for every caller **including workspace app
        admins**, matching the web and mobile lists. An admin's list is the
        workspaces they are part of; any other workspace in the tenant is
        still reachable by addressing it directly at
        `GET /workspace/workspaces/{id}`.

        Ordered most-recently-updated first. Each row's `role` is resolved for
        the caller: their membership role, `member` for a rule-group member
        with no explicit row, or `null` when they have no role in it.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: Which workspaces to include. `active` (the default), `archived`,
          `draft` (the drafts you own — not launched yet), or `all`. An unrecognised
          value behaves as `active`.
        schema:
          type: string
          enum:
          - active
          - archived
          - draft
          - all
          default: active
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: The caller's workspaces
          content:
            application/json:
              schema:
                type: object
                required:
                - workspaces
                - total_count
                - meta
                properties:
                  workspaces:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Workspace"
                  total_count:
                    type: integer
                    description: Workspaces matching the filter, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Create a workspace
      description: |
        Creates a workspace and makes the caller its owner.

        Who may create is a tenant capability policy (the Workspace app's
        `create_workspace` setting — manager-or-above by default, widenable to
        specific groups or everyone). A caller the policy excludes gets `422`
        naming the reason rather than a silent no-op.

        The new workspace has NO task list. `POST .../tasks` creates a default
        one on first use, so no extra call is needed.

        **From a template.** Send `template` (a slug or id from
        `GET /workspace/templates`) to build the workspace the way the web
        create drawer does: the template's sections, lists and seed content
        are applied, and the template's own `kind` is the default unless you
        send one. Content the template declares but that could not be applied
        does not fail the call — the workspace is created and `201` carries a
        `warnings` list naming what is missing. An unknown template is `422`.

        `kind`, `tags`, `ends_on` and `series_cadence` are checked by the same
        rules the web, the Ask AI agent and Smart Import apply; a value outside
        them is `422` naming the accepted values.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry key retained by the platform for 24 hours. A retry carrying
          the same key replays the original response (header `X-Idempotency-Cached:
          true`) instead of writing again. Error responses are not replayed.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                  description: The workspace name.
                  example: Acme Q3 Rollout
                description:
                  type: string
                  nullable: true
                icon:
                  type: string
                  nullable: true
                  description: Icon key, as shown in the web workspace form.
                color:
                  type: string
                  nullable: true
                  description: Colour key, as shown in the web workspace form.
                template:
                  type: string
                  description: 'Build the workspace from this template — its `slug`
                    or numeric `id` from `GET /workspace/templates`. A slug is looked
                    up among the system templates first, then the tenant''s own; an
                    all-digit value that matches no slug is read as an id. An unknown
                    template is `422` ("Template <ref> not found."). Must be a single
                    value — an array or object is `422` ("template must be a single
                    id."). A workspace built from a template is created as a DRAFT
                    (`status: draft`): only its owners can see it, nobody is notified,
                    and its to-dos wait on their roles until `POST /workspace/workspaces/{id}/launch`.'
                  example: customer-onboarding
                draft:
                  type: boolean
                  description: 'Blank workspaces only: create it as a draft — owners-only
                    and silent until launched. A workspace from a template is always
                    a draft.'
                template_slug:
                  type: string
                  description: Alias for `template`, used only when `template` is
                    absent. Must be a single value — an array or object is `422` ("template_slug
                    must be a single id.").
                template_id:
                  type: string
                  description: Alias for `template`, used only when neither `template`
                    nor `template_slug` is sent. Accepts an id or a slug. Must be
                    a single value — an array or object is `422` ("template_id must
                    be a single id.").
                anchor_date:
                  type: string
                  format: date
                  description: 'For a dated template (`setup.scheduled`): the date
                    its plan counts from — go-live, opening day, the deadline (`setup.anchor_label`).
                    Each to-do and milestone is dated from it; a negative offset counts
                    back. Omitted, the plan is created with no dates and a warning
                    says so. An unreadable date is `422`.'
                  example: '2027-03-01'
                roles:
                  type: object
                  additionalProperties:
                    type: string
                  description: 'Who holds each of the template''s role slots, as role
                    `key` (from `setup.roles`) → user id. The person joins the workspace
                    and owns that role''s to-dos, milestones and risks. A key sent
                    empty leaves the slot open; a key not sent falls back to the slot''s
                    default (`defaults_to_creator`: the caller). An open slot''s to-dos
                    stay unassigned with `unfilled_role` naming it, and a warning
                    lists it.'
                  example:
                    site_manager: '4211'
                    it_lead: ''
                kind:
                  type: string
                  nullable: true
                  enum:
                  - store
                  - district
                  - region
                  - team
                  - project
                  - client_account
                  - committee
                  - training_cohort
                  - mentoring
                  - social
                  - legal_matter
                  - tracker
                  - event
                  - release
                  - other
                  -
                  description: What sort of workspace this is. With `template`, defaults
                    to the template's `kind`; a kind you send wins. Blank is ignored.
                    Any other value is `422`.
                tags:
                  description: Free-form tags, as a JSON array of strings or one comma-separated
                    string. Blank entries are dropped and duplicates removed case-insensitively.
                    At most 20 tags, each 40 characters or fewer; more is `422` rather
                    than a silent truncation.
                  oneOf:
                  - type: array
                    items:
                      type: string
                  - type: string
                  example:
                  - onboarding
                  - q3
                ends_on:
                  type: string
                  format: date
                  nullable: true
                  description: The date the workspace's work ends (YYYY-MM-DD), which
                    drives the close-out prompt. A value that is not a date between
                    the years 1900 and 9999 is `422`.
                  example: '2026-12-31'
                series_cadence:
                  type: string
                  nullable: true
                  enum:
                  - yearly
                  - quarterly
                  - monthly
                  -
                  description: For a workspace that recurs as a series, how often.
                    Any other value is `422`.
                starts_on:
                  type: string
                  format: date
                  nullable: true
                  description: The date the project starts (YYYY-MM-DD). Not after
                    `ends_on`; a value that is not a date between the years 1900 and
                    9999 is `422`.
                  example: '2026-10-01'
                health:
                  type: string
                  enum:
                  - on_track
                  - at_risk
                  - off_track
                  - not_set
                  description: The owner's own rating of how the project is going.
                    Defaults to `not_set`; any other value is `422`.
                organizational_department_id:
                  type: integer
                  nullable: true
                  description: The department the project belongs to — an active department
                    of this business, else `422`.
                sponsor_id:
                  type: integer
                  nullable: true
                  description: The project's sponsor (the accountable leader) — an
                    active person in this business, else `422`.
      responses:
        '201':
          description: The created workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace
                properties:
                  workspace:
                    "$ref": "#/components/schemas/Workspace"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Present only when the workspace was created from
                      a `template` and some of the template's content could not be
                      applied (for example its welcome message could not be posted).
                      The workspace exists; each entry says what it lacks and where
                      to set it up.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was created (error code `validation_failed`). The attributes
            are invalid (blank name, name already taken, a `kind`, `series_cadence`,
            `tags` or `ends_on` outside the accepted values), the `template` names
            no template visible to this business ("Template <ref> not found."), `template`
            / `template_slug` / `template_id` was an array or object ("<key> must
            be a single id."), or the tenant's `create_workspace` policy does not
            admit the caller. `error.details` carries the individual messages when
            the service refused.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: One workspace
      description: |
        A single workspace, addressable by numeric id OR slug. Carries
        `health_signals` beside the owner's own `health` (PM-3): what the data
        shows.

        Members and rule-group members see the workspaces they belong to;
        workspace app admins reach any workspace in the tenant, matching the
        web (`authorize_member!` admits app admins) and mobile surfaces.
        Anything else is `404`, never `403`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace
                properties:
                  workspace:
                    allOf:
                    - "$ref": "#/components/schemas/Workspace"
                    - type: object
                      properties:
                        health_signals:
                          "$ref": "#/components/schemas/WorkspaceHealthSignals"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business, or not accessible
            to the caller (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Workspace
      summary: Update a workspace
      description: |
        The correction path for create — rename, re-describe, restyle, and
        reclassify (`kind`, `tags`) or reschedule (`ends_on`,
        `series_cadence`).

        OWNER-ONLY (a workspace app admin also qualifies), the same rule the
        web applies with `authorize_owner!`. The gate lives in the shared
        service, so this surface, the web and the Ask AI agent cannot drift.

        **Only the keys you send are written.** A request carrying just `name`
        leaves the description untouched — it does not blank it. For `kind`,
        `tags`, `ends_on` and `series_cadence`, sending the key with a blank
        value (`null` or `""`) CLEARS it. Send at least one updatable key, or
        the request is refused with `422`.

        `kind`, `tags`, `ends_on` and `series_cadence` are checked by the same
        rules as on create; a value outside them is `422` naming the accepted
        values.

        `PUT` is routed to the same action and behaves identically.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry key retained by the platform for 24 hours. A retry carrying
          the same key replays the original response (header `X-Idempotency-Cached:
          true`) instead of writing again. Error responses are not replayed.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                description:
                  type: string
                  nullable: true
                icon:
                  type: string
                  nullable: true
                color:
                  type: string
                  nullable: true
                kind:
                  type: string
                  nullable: true
                  enum:
                  - store
                  - district
                  - region
                  - team
                  - project
                  - client_account
                  - committee
                  - training_cohort
                  - mentoring
                  - social
                  - legal_matter
                  - tracker
                  - event
                  - release
                  - other
                  -
                  description: What sort of workspace this is. Blank clears it. Any
                    other value is `422`.
                tags:
                  description: 'REPLACES the workspace''s tags: a JSON array of strings
                    or one comma-separated string. Blank entries are dropped and duplicates
                    removed case-insensitively; a blank value or an empty list clears
                    every tag. At most 20 tags, each 40 characters or fewer; more
                    is `422` rather than a silent truncation.'
                  oneOf:
                  - type: array
                    items:
                      type: string
                  - type: string
                  nullable: true
                ends_on:
                  type: string
                  format: date
                  nullable: true
                  description: The date the workspace's work ends (YYYY-MM-DD). Blank
                    clears it. A value that is not a date between the years 1900 and
                    9999 is `422`.
                series_cadence:
                  type: string
                  nullable: true
                  enum:
                  - yearly
                  - quarterly
                  - monthly
                  -
                  description: Blank clears it. Any other value is `422`.
                starts_on:
                  type: string
                  format: date
                  nullable: true
                  description: The date the project starts (YYYY-MM-DD). Blank clears
                    it. A start after the end date, or a value that is not a date
                    between the years 1900 and 9999, is `422`.
                health:
                  type: string
                  nullable: true
                  enum:
                  - on_track
                  - at_risk
                  - off_track
                  - not_set
                  -
                  description: The owner's own rating of how the project is going.
                    Blank resets it to `not_set`; any other value is `422`.
                organizational_department_id:
                  type: integer
                  nullable: true
                  description: The department the project belongs to — an active department
                    of this business, else `422`. Blank clears it.
                sponsor_id:
                  type: integer
                  nullable: true
                  description: The project's sponsor — an active person in this business,
                    else `422`. Blank clears it.
      responses:
        '200':
          description: The updated workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace
                properties:
                  workspace:
                    "$ref": "#/components/schemas/Workspace"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled or not accessible, or the
            caller is neither the workspace owner nor a workspace app admin (error
            code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was written (error code `validation_failed`) — the
            body carried none of the updatable keys ("Supply at least one of name,
            description, icon, color, kind, tags, ends_on, series_cadence, starts_on,
            health, organizational_department_id, sponsor_id."), `name` / `description`
            / `icon` / `color` was not a string, a `kind`, `series_cadence`, `tags`,
            `ends_on`, `starts_on` or `health` value is outside the accepted values,
            the department or sponsor is not an active one of this business, or the
            attributes are otherwise invalid (including a content-moderation refusal
            of the name or description).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{id}/client-digest":
    parameters:
    - name: id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: Generate the external digest
      description: |
        Renders the workspace's client-facing digest as Markdown — the public
        message highlights, completed tasks and hill-chart positions a client
        may see.

        **OWNER-ONLY** (a workspace app admin also qualifies). This assembles
        exactly what leaves the company, so membership alone is not enough: a
        read-only viewer must not be able to produce it.

        Also requires the business-level `client_digest_enabled` toggle.

        Content marked internal-only anywhere in the workspace is excluded by
        construction — the same redaction the web digest and external access
        apply.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The generated digest
          content:
            application/json:
              schema:
                type: object
                required:
                - workspace_id
                - format
                - digest
                properties:
                  workspace_id:
                    type: integer
                  format:
                    type: string
                    enum:
                    - markdown
                  digest:
                    type: string
                    description: The digest body, in Markdown.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is not the workspace owner (nor a workspace app
            admin), or the external digest is turned off for this business (error
            code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: The digest could not be generated (error code `digest_failed`,
            "Couldn't generate the external digest. Please try again."). This is a
            server-side failure, not a problem with the request — retrying the same
            call is the right response. The message is deliberately fixed; the underlying
            detail is logged server-side rather than returned.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{id}/leave":
    parameters:
    - name: id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    post:
      tags:
      - Workspace
      summary: Leave a workspace
      description: |
        Takes the token's user out of the workspace — only their own
        membership; there is no way to aim it at anyone else (removing another
        member is an owner action on the web roster). Their open tasks in the
        workspace become unassigned, and the change is recorded in the audit
        trail like every membership change.

        Refused when:
        * they are the workspace's only owner — someone else must be made an
          owner first (`409 last_owner`);
        * the workspace's access rule admits them — people a rule includes
          cannot be removed one by one; they can mute the workspace in the app
          instead, or an owner can change the rule (`409 access_rule`);
        * they have no membership to leave (`422 not_a_member`);
        * the workspace is archived (`403 forbidden`).

        Delete-level (owner decision 2026-10-04): requires the `destructive:workspace`
        scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller left the workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - left
                - workspace_id
                - role
                - unassigned_task_count
                properties:
                  left:
                    type: boolean
                    enum:
                    - true
                  workspace_id:
                    type: integer
                  role:
                    type: string
                    enum:
                    - owner
                    - member
                    - viewer
                    description: The role they held.
                  unassigned_task_count:
                    type: integer
                    description: How many of their open tasks in the workspace were
                      unassigned.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The workspace is archived (error code `forbidden`), or the
            token lacks `destructive:workspace` (error code `insufficient_permissions`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: 'The caller cannot leave: they are the only owner (error code
            `last_owner`), or the access rule admits them (error code `access_rule`,
            with `details.rule_name`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The caller has no membership in this workspace to leave (error
            code `not_a_member`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{id}/launch":
    parameters:
    - name: id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    post:
      tags:
      - Workspace
      summary: Launch a draft workspace
      description: |
        Makes a draft live. Until now only its owners could see it and nobody
        was notified. Launching tells every member they were added, assigns each
        template to-do to its role's holder (one notice per holder), starts the
        check-ins on their schedule and announces the welcome post.
        Owners only (`403 forbidden`). Launching a workspace that is already live
        succeeds with `launched: false`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                roles:
                  type: object
                  description: 'Optional `{ role_key: user_id }` — change who holds
                    a template role before anyone is told. A holder replaced who was
                    only in the workspace for that role is taken back out.'
                  additionalProperties:
                    type: string
      responses:
        '200':
          description: Launched (or already live).
          content:
            application/json:
              schema:
                type: object
                properties:
                  workspace:
                    type: object
                  launched:
                    type: boolean
                  to_dos_assigned:
                    type: integer
                  members_notified:
                    type: integer
        '403':
          description: Not an owner of the workspace.
        '422':
          description: A picked role holder isn't an active member of the organization,
            or roles isn't an object.
  "/workspace/workspaces/{workspace_id}/messages":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List message-board posts
      description: |
        The workspace's message board, most recent first. Every row carries a
        `comment_count`, so a caller can tell which threads have replies
        without fetching them.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Message-board posts
          content:
            application/json:
              schema:
                type: object
                required:
                - messages
                - total_count
                - meta
                properties:
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceMessage"
                  total_count:
                    type: integer
                    description: Posts on this board, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Post to the message board
      description: |
        Posts a new message as the caller. CONTRIBUTORS ONLY — a member whose
        role is `viewer` gets `403`.

        `@mentions` in the body are extracted and notified by the same service
        the web uses.

        Send `Idempotency-Key` to make a network retry replay the original
        response without posting again. `external_id` is a durable identity:
        sending the same payload and id later returns the existing message;
        reusing it for different content returns `409`.

        **Attach files with the post.** Send the body as `multipart/form-data`
        with `attachments[]` file parts (at most 10) alongside the fields. The
        bytes enter through Rails, are pushed to storage concurrently and land
        through the same Drive pipeline as the web composer — one request, no
        upload URLs, nothing to clean up. This replaces the deprecated
        prepare/upload/commit publication flow. A refused file (over its cap —
        10 MB, or 100 MB when the REAL bytes are video) fails the call with
        `422` and nothing is posted or stored. On an `external_id` replay the
        files are matched by byte checksum, so a retried post never doubles its
        attachments. Failures from the storage layer AFTER the post exists are
        reported as `attachment_errors` rather than losing the post.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - body
              properties:
                title:
                  type: string
                body:
                  type: string
                  description: Message body. `@mentions` are extracted and notified.
                internal_only:
                  type: boolean
                  default: false
                  description: Excludes the post from the external digest and external
                    access. Omitted means `false`.
                external_id:
                  type: string
                  maxLength: 255
                  description: Optional caller-owned durable identity, unique within
                    this workspace. Use it for resumable publishing jobs.
                visible_to:
                  type: string
                  enum:
                  - members
                  - owners
                  description: '`members` (everyone, the default) or `owners` (leads
                    only). `owners` from a caller who is neither the workspace owner
                    nor a Workspace app admin is `403` rather than a silent downgrade;
                    any other value is `422` ("visible_to must be one of: members,
                    owners.").'
                kind:
                  type: string
                  enum:
                  - standard
                  - announcement
                  default: standard
                  description: '`announcement` posts a banner announcement, which
                    reaches members who muted ordinary posts. Only the workspace owner
                    or a Workspace app admin; anyone else is `403` ("Only the workspace
                    owner or a Workspace admin can post an announcement.").'
                cascade_to_locations:
                  type: boolean
                  default: false
                  description: 'Also post a copy into every location workspace beneath
                    this workspace''s location (a district''s stores). Only the workspace
                    owner or a Workspace app admin (`403` otherwise), never with `visible_to:
                    owners` (`422`). Each copy carries the title and body; attachments
                    stay on this post. The response''s `cascade` object counts the
                    copies that `landed`, those `held_for_review` by a store''s owner,
                    and the stores `not_posted` (a store whose message board is off,
                    one that removed or declined an earlier copy, or any past the
                    first 100).'
          multipart/form-data:
            schema:
              type: object
              required:
              - title
              - body
              properties:
                title:
                  type: string
                body:
                  type: string
                internal_only:
                  type: boolean
                  default: false
                external_id:
                  type: string
                  maxLength: 255
                visible_to:
                  type: string
                  enum:
                  - members
                  - owners
                kind:
                  type: string
                  enum:
                  - standard
                  - announcement
                cascade_to_locations:
                  type: boolean
                  default: false
                attachments:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    format: binary
                  description: File parts posted WITH the message.
      responses:
        '200':
          description: Existing message replayed for the same `external_id` and identical
            content.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - replayed
                - pending_review
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - true
                    description: 'True — this is the post an earlier call with the
                      same `external_id` created. The `X-External-Id-Replayed: true`
                      header says the same.'
                  pending_review:
                    type: boolean
                    description: True when the existing post is awaiting review under
                      the workspace's posting policy (`message.review_status` is `pending_review`)
                      — it is not yet visible to the roster. A replayed post that
                      was `rejected` answers false here; read `message.review_status`
                      for the full state.
                  sent_back_for_review:
                    type: boolean
                    enum:
                    - true
                    description: Present (and true) only when this replay attached
                      a NEW file to a post that was already live, and that sent the
                      post back for review under the posting policy. Absent otherwise.
                  attachment_errors:
                    type: array
                    items:
                      type: string
                    description: Present only when a file could not be stored; the
                      post itself landed.
        '201':
          description: The created message
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - replayed
                - pending_review
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - false
                    description: False — a new post was created.
                  pending_review:
                    type: boolean
                    description: 'True when the workspace''s posting policy is holding
                      the new post for review (`message.review_status` is `pending_review`):
                      the `201` means it was saved, not that the roster can see it
                      yet. It goes live when the owner or a Workspace app admin approves
                      it.'
                  attachment_errors:
                    type: array
                    items:
                      type: string
                    description: Present only when a file could not be stored; the
                      post itself landed.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The app or the message board is disabled, the caller is not
            a member, the caller is a read-only viewer, or `visible_to: owners` was
            sent by a caller who is neither the workspace owner nor a Workspace app
            admin (error code `forbidden`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The idempotency key or durable `external_id` was reused for
            a different request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was created; the message is invalid (error code `validation_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One message with its comments
      description: |
        A single post together with a page of its comment thread.

        The thread is PAGED, oldest comment first (by `created_at`, then `id`,
        so a tie never straddles a page boundary twice). A call returns one
        page — `comment_per_page` comments (default 25, maximum 100) of page
        `comment_page` (default 1) — and `message.comments_meta` describes
        where that page sits, in the same shape as the `meta` block on every
        collection here. To walk the whole thread, repeat the call with
        `comment_page` + 1 while `comments_meta.has_next_page` is true; the
        newest replies are on page `comments_meta.total_pages`.
        `comment_count` on the message is the thread's true total.

        A comment posted through Workspace external access has no platform
        account behind it: those carry `author.external: true`, the client's
        address as `author.email`, and a null `author.id`. This mirrors what
        the web shows internal readers.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: comment_page
        in: query
        required: false
        description: Which page of the comment thread to return, 1-based. A blank
          or non-numeric value returns page 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: comment_per_page
        in: query
        required: false
        description: Comments per page. Maximum 100; a larger value is clamped to
          it, and a blank or non-numeric value falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: comment_limit
        in: query
        required: false
        description: Alias for `comment_per_page`, kept because it is what this endpoint
          shipped with. `comment_per_page` wins when both are sent.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: The message and a page of its comments
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    allOf:
                    - "$ref": "#/components/schemas/WorkspaceMessage"
                    - type: object
                      properties:
                        comments:
                          type: array
                          description: One page of the thread, oldest first.
                          items:
                            "$ref": "#/components/schemas/WorkspaceMessageComment"
                        comments_meta:
                          allOf:
                          - "$ref": "#/components/schemas/WorkspacePaginationMeta"
                          description: Pagination state for `comments`, scoped to
                            this thread. Unlike a collection's `meta`, it is NOT mirrored
                            in the `X-*` pagination headers.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or message (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Workspace
      summary: Edit a message
      description: |
        The correction path for posting. **Author-only** — or a workspace app
        admin. A contributor may post their own message but must not rewrite a
        colleague's, the same rule the web applies.

        **Only the keys you send are written**, so a request carrying just
        `title` will not blank the body. Send at least one of `title`, `body`,
        `internal_only`, `visible_to`, or the request is refused with `422`
        ("Supply at least one of title, body, internal_only, visible_to.").

        `visible_to` moves the post between everyone (`members`) and leads
        only (`owners`). Only the workspace owner or a Workspace app admin can
        change it: `owners` from anyone else is `403`, and `members` from
        anyone else is accepted but leaves the stored value unchanged. Any
        other value is `422`.

        Under a posting policy that holds member posts for review, an edit that
        changes the `title` or `body` of a non-reviewer's post sends it back
        for review (`review_status: pending_review`) and clears any
        `review_notes`; reviewers' own edits never re-enter review.

        `@mentions` are re-extracted on edit. `PUT` behaves identically.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                  description: Must be a string — an array or object is `422` ("title
                    must be a string.").
                body:
                  type: string
                  description: Must be a string — an array or object is `422` ("body
                    must be a string."). `@mentions` are re-extracted.
                internal_only:
                  type: boolean
                  description: Null is read as `false`.
                visible_to:
                  type: string
                  enum:
                  - members
                  - owners
                  description: '`members` (everyone in the workspace) or `owners`
                    (leads only). `owners` from a caller who is neither the workspace
                    owner nor a Workspace app admin is `403` ("Only the workspace
                    owner (or a Workspace app admin) can post to leads only."); from
                    such a caller `members` is accepted but not applied. Any other
                    value is `422` ("visible_to must be one of: members, owners."),
                    as is a non-string ("visible_to must be a string.").'
                kind:
                  type: string
                  enum:
                  - standard
                  - announcement
                  description: Marks the post an announcement, or un-marks one. Changing
                    it is the workspace owner's or a Workspace app admin's call (`403`
                    otherwise); a meeting note or status update keeps its kind (`422`).
      responses:
        '200':
          description: The updated message
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The app or the message board is disabled, the caller is not
            a member, the caller is a read-only viewer, the caller is neither the
            message''s author nor a workspace admin, or `visible_to: owners` was sent
            by a caller who is neither the workspace owner nor a Workspace app admin
            (error code `forbidden`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or message (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The idempotency key was reused for a different request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was written (error code `validation_failed`) — the
            body carried none of the editable keys, `title` / `body` / `visible_to`
            was not a string, `visible_to` was neither `members` nor `owners`, or
            the values are invalid (including a content-moderation refusal).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{id}/approve":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Approve a held post
      description: |
        Publishes a post the workspace's posting policy is holding for review
        (`review_status: pending_review`). The post goes live, the workspace
        roster is notified of it — once per post: a post that was approved
        before, edited and re-held, is not announced to the roster a second
        time, though anyone the edit newly @-mentions is told — and the author
        is told it was approved. Any earlier `review_notes` are cleared.

        **Reviewers only** — the workspace owner or a Workspace app admin.
        Anyone else, including the post's author and other contributors, gets
        `403`. The workspace must not be archived.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The approved post, now live
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Nothing was decided (error code `forbidden`). The app or the
            message board is disabled, the caller is not a member, the workspace is
            archived, or the caller is neither the workspace owner nor a Workspace
            app admin ("Only the workspace owner or a Workspace admin can review posts.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or no message with that id the caller can
            read (a leads-only post, or someone else's held post, is `404` to anyone
            who could not open it) — error code `not_found`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was decided (error code `validation_failed`, the reason
            in `error.message` and `error.details`) — most often "This post is not
            awaiting review." for a post that is already published or was rejected;
            rarely, the post's own validation messages.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{id}/reject":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Reject a held post
      description: |
        Turns down a post the workspace's posting policy is holding for review
        (`review_status: pending_review`). The post is NOT deleted: it moves to
        `rejected`, stays readable by its author and the reviewers only, and
        carries the reason in `review_notes`. The author is notified, and
        revising the post sends it back for review.

        **Reviewers only** — the workspace owner or a Workspace app admin.
        Anyone else, including the post's author and other contributors, gets
        `403`. The workspace must not be archived.

        Requires the business-level `enable_message_board` toggle.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:workspace` scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - review_notes
              properties:
                review_notes:
                  type: string
                  maxLength: 1000
                  description: 'The reason, shown to the author so they can revise
                    the post. Required: blank or missing is `422` ("Give the author
                    a reason so they can revise the post."), and so is a non-string
                    value. Leading and trailing whitespace is trimmed and CRLF counts
                    as one character; more than 1,000 characters is `422` ("Keep the
                    reason to 1,000 characters or fewer.").'
                  example: Please add the store hours before this goes out.
      responses:
        '200':
          description: The rejected post
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Nothing was decided (error code `forbidden`). The app or the
            message board is disabled, the caller is not a member, the workspace is
            archived, or the caller is neither the workspace owner nor a Workspace
            app admin ("Only the workspace owner or a Workspace admin can review posts.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or no message with that id the caller can
            read (a leads-only post, or someone else's held post, is `404` to anyone
            who could not open it) — error code `not_found`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: 'Nothing was decided (error code `validation_failed`, the reason
            in `error.message` and `error.details`): "This post is not awaiting review.",
            "Give the author a reason so they can revise the post.", "Keep the reason
            to 1,000 characters or fewer.", or — rarely — the post''s own validation
            messages.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{id}/mark-reviewed":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Mark a post as still accurate
      description: |
        Records that someone checked the post and it is still right — the same
        "Still accurate" action the post's page offers. Content governance
        nudges a workspace's owners weekly about posts it judges stale, and an
        owner may not edit a member's post; this is how they clear the nudge
        without deleting the post.

        Sets `last_reviewed_at` to now and nothing else: `updated_at` does not
        move, the post is not re-sent for review and nobody is notified. The
        post's open stale-content governance findings are resolved at once, and
        the next scan ages the post from this check. Calling it again simply
        moves the date.

        **Who may call it:** the post's author, any owner of the workspace, or
        a Workspace app admin. Only a post on the board (`review_status:
        published`) can be marked; the workspace must not be archived.

        Requires the business-level `enable_message_board` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The post, with `last_reviewed_at` set to now
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Nothing was recorded (error code `forbidden`). The app or the
            message board is disabled, the caller is not a member, the workspace is
            archived, or the caller is not the author, a workspace owner or a Workspace
            app admin ("Only the author, a workspace owner or a Workspace admin can
            mark this post as still accurate.").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or no message with that id the caller can
            read (a leads-only post, or someone else's held post, is `404` to anyone
            who could not open it) — error code `not_found`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: 'Nothing was recorded (error code `validation_failed`): the
            post is not on the board — held for review or not approved ("Only a post
            on the board can be marked as still accurate.").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{message_id}/attachments":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    - name: message_id
      in: path
      required: true
      description: The message id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Attach files to a message
      description: |
        Adds files to a message authored by the caller. Workspace app admins
        may manage any message. The upload follows the same Drive attachment
        pipeline as the web composer.

        Send the body as `multipart/form-data` with `attachments[]` file parts
        (at most 10 per request); a single `file` part is the older shape and
        still works. The bytes are pushed to storage CONCURRENTLY before
        anything is attached, so several files cost one round trip rather than
        one each.

        Ordinary files may be up to 10 MB; files whose REAL bytes are video may
        be up to 100 MB — the cap is applied to the sniffed media type as well
        as the declared one, so an executable renamed `.mp4` is refused (`422`)
        and its bytes are purged in the same request. Refusals are
        all-or-nothing: nothing is attached.

        Replay is per file, by byte checksum: a request whose files are all
        already attached returns `200` with `replayed: true` and the
        `X-Attachment-Replayed` header, instead of storing duplicates.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                attachments:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    format: binary
                  description: The file parts. Use `file` for a single upload.
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: Every file in the request was already attached; the existing
            attachments were replayed.
          content:
            application/json:
              schema:
                type: object
                required:
                - attachment
                - attachments
                - message
                - replayed
                properties:
                  attachment:
                    allOf:
                    - "$ref": "#/components/schemas/WorkspaceMessageAttachment"
                    description: The FIRST attachment, for single-file clients.
                  attachments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceMessageAttachment"
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - true
        '201':
          description: Files attached
          content:
            application/json:
              schema:
                type: object
                required:
                - attachment
                - attachments
                - message
                - replayed
                properties:
                  attachment:
                    allOf:
                    - "$ref": "#/components/schemas/WorkspaceMessageAttachment"
                    description: The FIRST attachment, for single-file clients.
                  attachments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceMessageAttachment"
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  replayed:
                    type: boolean
                    enum:
                    - false
                  attachment_errors:
                    type: array
                    items:
                      type: string
                    description: Present only when some file could not be stored.
        '400':
          description: No multipart file was supplied (error code `invalid_file`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller cannot contribute to or manage this message.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or message (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The file failed validation or storage (error code `attachment_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/messages/{message_id}/attachments/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      schema:
        type: string
    - name: message_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The Drive attachment id returned by the message API.
      schema:
        type: integer
    delete:
      tags:
      - Workspace
      summary: Remove a message attachment
      description: |
        Soft-deletes one attachment. Author-only, or a Workspace app admin.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:workspace` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      responses:
        '200':
          description: Attachment removed
          content:
            application/json:
              schema:
                type: object
                required:
                - removed_attachment_id
                - message
                properties:
                  removed_attachment_id:
                    type: integer
                  message:
                    "$ref": "#/components/schemas/WorkspaceMessage"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller cannot contribute to or manage this message.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, message, or active attachment.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The idempotency key was reused for a different request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The attachment could not be removed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List workspace tasks
      description: |
        The workspace's canvas tasks. Distinct from the org-wide Tasks API at
        `/api/v1/tasks`: both read the same underlying records, and these are
        the ones attached to a workspace.

        Ordered by due date, undated last, with a stable tiebreaker — so
        paging never shows the same task twice or skips one.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: "`active` (the default — pending, in progress or overdue; excludes
          completed and cancelled), `completed`, `cancelled`, `overdue` (past due
          and neither completed nor cancelled), or `all`. An unrecognised value behaves
          as `active`."
        schema:
          type: string
          enum:
          - active
          - completed
          - cancelled
          - overdue
          - all
          default: active
      - name: assignee_id
        in: query
        required: false
        description: Only tasks assigned to this user. Must be a single numeric id
          — an array or object is `422` (`validation_failed`, "assignee_id must be
          a single id."), and a value that is not all digits (`abc`, `12abc`) is `422`
          ("assignee_id must be a numeric id.") rather than a filter that silently
          matches nothing or someone else.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 200.
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Tasks on this workspace
          content:
            application/json:
              schema:
                type: object
                required:
                - tasks
                - total_count
                - meta
                properties:
                  tasks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceTask"
                  total_count:
                    type: integer
                    description: Tasks matching the filters, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`assignee_id` was an array or object rather than a single
            id (error code `validation_failed`, "assignee_id must be a single id."),
            or not all digits ("assignee_id must be a numeric id.").'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Create a task
      description: |
        Adds a task to the workspace. CONTRIBUTORS ONLY.

        `task_list_id` (alias `list_id`) is OPTIONAL. A workspace created
        through this API has no task list, and this namespace exposes no
        task-list resource — so when none is given the first active list is
        reused, or a list named "Default" is created. Supplying a list id that
        is not in this workspace is `404`, which is distinct from supplying
        none.

        An `assignee_id` that resolves to nobody in the business is `404`, not
        a silent unassigned create; an unparseable `due_at` is `422`, not a
        silent "no due date". Assigning a non-member is refused by the service
        with `422` naming the reason.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry key retained by the platform for 24 hours. A retry carrying
          the same key replays the original response (header `X-Idempotency-Cached:
          true`) instead of writing again. Error responses are not replayed.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                notes:
                  type: string
                  nullable: true
                assignee_id:
                  type: integer
                  nullable: true
                  description: A user in this business who is a member of the workspace.
                    Must be a single id — an array or object is `422` ("assignee_id
                    must be a single id.").
                due_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: ISO 8601 datetime.
                task_list_id:
                  type: integer
                  nullable: true
                  description: A task list in THIS workspace. Omit to use (or create)
                    the workspace's default list. `list_id` is accepted as an alias.
                    Must be a single id — an array or object is `422` ("task_list_id
                    must be a single id.").
                list_id:
                  type: integer
                  nullable: true
                  description: Alias for `task_list_id`. Must be a single id — an
                    array or object is `422` ("list_id must be a single id.").
                internal_only:
                  type: boolean
                  default: false
                  description: Excludes the task from the external digest and external
                    access.
                recurring_cadence:
                  type: string
                  nullable: true
                  enum:
                  - daily
                  - weekly
                  - monthly
                  -
                  description: Repeat the task on this cadence. Refused with `422`
                    while recurring to-dos are turned off for the workspace.
                parent_task_id:
                  type: integer
                  nullable: true
                  description: Create a SUBTASK under this top-level to-do of the
                    workspace (a milestone's deliverable). A subtask has no list (`list_id`
                    is null) and cannot have subtasks of its own; at most 100 per
                    to-do. An id not in this workspace is `404`.
                start_date:
                  type: string
                  format: date
                  nullable: true
                  description: The day work starts (PM-1). A date, or null to clear
                    it. It may not be after the due date (`422`).
                priority:
                  type: string
                  enum:
                  - normal
                  - important
                  - critical
                  description: Blank or null sets `normal`.
                estimated_duration_minutes:
                  type: integer
                  nullable: true
                  minimum: 1
                  description: The estimate, in minutes. Null clears it.
                milestone:
                  type: boolean
                  description: A milestone (PM-3) — a point in the plan; its subtasks
                    are its deliverables. A subtask cannot be a milestone.
                custom_fields:
                  type: object
                  additionalProperties: true
                  description: This workspace's custom to-do fields as `field_name`
                    (or the definition id) → value, checked against each field's type
                    (see `GET …/custom-fields`). A value that fails its type is `422`.
                    Fields you leave out keep their value.
                collaborator_ids:
                  type: array
                  items:
                    type: integer
                  description: The collaborators beside the one accountable owner
                    (`assignee_id`) — active members of the workspace, at most 20.
                    Replaces the list; `[]` clears it.
      responses:
        '201':
          description: The created task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, the given `task_list_id` is not in this
            workspace, or the given `assignee_id` resolves to nobody in the business
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was created (error code `validation_failed`) — an unparseable
            `due_at`, an assignee who is not a workspace member, an invalid title,
            a `recurring_cadence` while recurring to-dos are turned off, an array
            or object where `task_list_id`, `list_id` or `assignee_id` expects a single
            id ("<key> must be a single id."), a value there that is not all digits
            ("<key> must be a numeric id."), or no default task list could be created.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The task id.
      schema:
        type: integer
    patch:
      tags:
      - Workspace
      summary: Edit a task
      description: |
        Retitle, re-note, reassign, reschedule, change the recurrence, or move
        the task to another list in the SAME workspace. CONTRIBUTORS ONLY —
        any contributor may edit any task on the canvas (unlike messages,
        which are author-only).

        **Only the keys you send are written**, so a request carrying just
        `title` will not clear the due date or unassign the task. Send at least
        one editable key, or the request is refused with `422`. Send
        `assignee_id: null` or `due_at: null` to clear those explicitly.

        An `assignee_id` that resolves to nobody is `404`; an unparseable
        `due_at` is `422` rather than a silent clear. `PUT` behaves
        identically.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry key retained by the platform for 24 hours. A retry carrying
          the same key replays the original response (header `X-Idempotency-Cached:
          true`) instead of writing again. Error responses are not replayed.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                notes:
                  type: string
                  nullable: true
                assignee_id:
                  type: integer
                  nullable: true
                  description: Null unassigns. An id that resolves to nobody is `404`.
                    Must be a single id — an array or object is `422` ("assignee_id
                    must be a single id.").
                due_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: ISO 8601 datetime. Null clears the due date.
                recurring_cadence:
                  type: string
                  nullable: true
                  description: "`daily`, `weekly`, `monthly`, or null to stop repeating.
                    Changing it while recurring to-dos are turned off is `422`; re-sending
                    the stored value is accepted."
                internal_only:
                  type: boolean
                task_list_id:
                  type: integer
                  description: A task list in this workspace. `list_id` is accepted
                    as an alias. Must be a single id — an array or object is `422`
                    ("task_list_id must be a single id.").
                list_id:
                  type: integer
                  description: Alias for `task_list_id`. Must be a single id — an
                    array or object is `422` ("list_id must be a single id.").
                start_date:
                  type: string
                  format: date
                  nullable: true
                  description: The day work starts (PM-1). A date, or null to clear
                    it. It may not be after the due date (`422`).
                priority:
                  type: string
                  enum:
                  - normal
                  - important
                  - critical
                  description: Blank or null sets `normal`.
                estimated_duration_minutes:
                  type: integer
                  nullable: true
                  minimum: 1
                  description: The estimate, in minutes. Null clears it.
                milestone:
                  type: boolean
                  description: A milestone (PM-3) — a point in the plan; its subtasks
                    are its deliverables. A subtask cannot be a milestone.
                custom_fields:
                  type: object
                  additionalProperties: true
                  description: This workspace's custom to-do fields as `field_name`
                    (or the definition id) → value, checked against each field's type
                    (see `GET …/custom-fields`). A value that fails its type is `422`.
                    Fields you leave out keep their value.
                collaborator_ids:
                  type: array
                  items:
                    type: integer
                  description: The collaborators beside the one accountable owner
                    (`assignee_id`) — active members of the workspace, at most 20.
                    Replaces the list; `[]` clears it.
      responses:
        '200':
          description: The updated task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
                  schedule_conflicts:
                    type: array
                    description: 'The finish-to-start links (PM-2a) this to-do now
                      clashes with — it starts before a to-do it waits on finishes,
                      or a to-do waiting on it does. Empty when none. Shift them with
                      `POST …/schedule` (`mode: shift`).'
                    items:
                      "$ref": "#/components/schemas/WorkspaceScheduleConflict"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, the
            caller is a read-only viewer, or the caller is neither a workspace owner
            nor a Workspace app admin and the request lifts `internal_only` (true
            → false), which shares the to-do with external guests (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or task, or the given `assignee_id` resolves
            to nobody in the business (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was written (error code `validation_failed`) — the
            body carried none of the editable keys, `due_at` is unparseable, `recurring_cadence`
            was changed while recurring to-dos are turned off, an array or object
            was sent where `task_list_id`, `list_id` or `assignee_id` expects a single
            id ("<key> must be a single id."), a value there that is not all digits
            ("<key> must be a numeric id."), `internal_only` was changed while external
            access and the external digest are both off, or the values are invalid.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks/{id}/complete":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The task id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Complete a task
      description: |
        Marks the task complete. For contributors, and for the task's own
        assignee whatever their workspace role (a read-only viewer may complete
        a task assigned to them). Idempotent enough to retry: the response
        always carries the task's current state.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The completed task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer who is not the task's assignee (error
            code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or task (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The task was not completed (error code `validation_failed`).
            `error.details` carries both the `errors` list and the task's current
            `task` state, so a client can re-render without a second call.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/tasks/{id}/reopen":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The task id.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Reopen a task
      description: |
        The undo for complete — clears the completion. For contributors, and
        for the task's own assignee whatever their workspace role.

        Requires the business-level `enable_tasks` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The reopened task
          content:
            application/json:
              schema:
                type: object
                required:
                - task
                properties:
                  task:
                    "$ref": "#/components/schemas/WorkspaceTask"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or Tasks is disabled, the caller is not a member, or
            the caller is a read-only viewer who is not the task's assignee (error
            code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or task (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The task was not reopened (error code `validation_failed`).
            `error.details` carries both the `errors` list and the task's current
            `task` state.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/custom-fields":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    get:
      tags:
      - Workspace
      summary: The project's custom to-do fields
      description: |
        This workspace's custom to-do fields (PM-1), in their order. A to-do
        carries their values under `custom_fields` (`field_name` → value), and
        create / update take the same shape. Members read; fields are managed on
        the web. Requires the `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      responses:
        '200':
          description: The fields
          content:
            application/json:
              schema:
                type: object
                required:
                - custom_fields
                - total_count
                - meta
                properties:
                  custom_fields:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceCustomField"
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a member, or Tasks is switched off (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/risks":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    get:
      tags:
      - Workspace
      summary: The project's risks
      description: |
        The project's risks (PM-3, decision D16) — Compliance Hub register rows
        sourced on this workspace, rated on a 5×5 likelihood × impact scale.
        Open ones by default (not accepted or closed); `status=all` for every
        one. Highest score first. Members read.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - open
          - all
          default: open
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      responses:
        '200':
          description: The risks
          content:
            application/json:
              schema:
                type: object
                required:
                - risks
                - total_count
                - meta
                properties:
                  risks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceRisk"
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a member (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Raise a risk
      description: |
        Raise a risk on the project. CONTRIBUTORS ONLY. Where Compliance Hub is
        enabled it also appears in the organization's risk register. There is no
        delete: a risk ends by closing it (`PATCH` with `status: closed`).
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry key retained by the platform for 24 hours.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/WorkspaceRiskInput"
      responses:
        '201':
          description: The risk
          content:
            application/json:
              schema:
                type: object
                required:
                - risk
                properties:
                  risk:
                    "$ref": "#/components/schemas/WorkspaceRisk"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A read-only viewer, or the workspace is archived (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Nothing was saved (error code `validation_failed`) — a rating
            outside 1–5, an unknown category or status, an owner who is not an active
            member of the project, or text the content screen refused.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/risks/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Workspace
      summary: Change a risk
      description: |
        Only the keys you send are written. The project's owners, a Workspace
        admin, whoever raised the risk and the risk's owner may change it
        (anyone else is `403`). Close it with `status: closed`. `PUT` behaves
        identically.

        **Token scopes:** `write:workspace`; ending the risk — `status: closed`
        or `status: accepted`, its two final statuses — also asks
        `destructive:workspace` (#7248).
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/WorkspaceRiskInput"
      responses:
        '200':
          description: The risk
          content:
            application/json:
              schema:
                type: object
                required:
                - risk
                properties:
                  risk:
                    "$ref": "#/components/schemas/WorkspaceRisk"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A viewer, not someone who may change this risk, or the workspace
            is archived.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such risk on this workspace.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: A value was refused, or nothing was sent (error code `validation_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/status-updates":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    post:
      tags:
      - Workspace
      summary: Post a project status update
      description: |
        An OWNER's project status update (PM-3): posted to the message board as
        a message of kind `status_update`, it sets the workspace's `health` when
        `health` is given and answers the owner-only status-update reminder.
        Internal-only by default: external access or the external digest never shows it
        unless `share_with_client` is true. Owners and Workspace admins only; the
        message board must be on.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - summary
              properties:
                summary:
                  type: string
                  maxLength: 10000
                  description: How the project is going and what the team should know.
                health:
                  type: string
                  enum:
                  - on_track
                  - at_risk
                  - off_track
                share_with_client:
                  type: boolean
                  default: false
                  description: Also show the update to the workspace's external guests
                    (external access and the external digest). Honoured only where
                    an external guest can read the board; otherwise the update stays
                    internal-only.
      responses:
        '201':
          description: The status update
          content:
            application/json:
              schema:
                type: object
                required:
                - status_update
                properties:
                  status_update:
                    "$ref": "#/components/schemas/WorkspaceMessage"
                  warnings:
                    type: array
                    items:
                      type: string
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not an owner, the board is off, or the workspace is archived
            (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: A blank or too-long summary, an unknown health, or refused
            text.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/dependencies":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    get:
      tags:
      - Workspace
      summary: The project's finish-to-start links
      description: |
        Every link between this workspace's to-dos (PM-2a): the successor may
        start `lag_days` after its predecessor finishes (a negative lag lets
        them overlap). `task_id` narrows to the links touching one to-do (either
        end). Members read. Requires the `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: task_id
        in: query
        required: false
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 500
          default: 100
      responses:
        '200':
          description: The links
          content:
            application/json:
              schema:
                type: object
                required:
                - dependencies
                - total_count
                - meta
                properties:
                  dependencies:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceDependency"
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a member, or Tasks is switched off.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`task_id` was not a single numeric id."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Workspace
      summary: Make a to-do wait on another
      description: |
        CONTRIBUTORS ONLY. `successor_id` will wait on `predecessor_id` — both
        top-level to-dos of this workspace (a subtask follows its parent). A
        loop is refused, as is a to-do waiting on more than 20 others. Sending a
        pair that is already linked changes its `lag_days` (`201` either way).
        `warnings` names what now starts too early; nothing is moved — shift
        with `POST …/schedule`.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - successor_id
              - predecessor_id
              properties:
                successor_id:
                  type: integer
                  description: The to-do that waits.
                predecessor_id:
                  type: integer
                  description: The to-do it waits on.
                lag_days:
                  type: integer
                  minimum: -365
                  maximum: 365
                  default: 0
                  description: Days after the predecessor finishes before the successor
                    may start.
      responses:
        '201':
          description: The link
          content:
            application/json:
              schema:
                type: object
                required:
                - dependency
                - warnings
                properties:
                  dependency:
                    "$ref": "#/components/schemas/WorkspaceDependency"
                  warnings:
                    type: array
                    items:
                      type: string
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A viewer, Tasks is off, or the workspace is archived.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`successor_id` or `predecessor_id` is not a to-do in this
            workspace."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Refused (error code `validation_failed`) — a loop, a subtask,
            a to-do waiting on itself, a lag outside -365..365, or too many links.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/dependencies/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Workspace
      summary: Change a link's gap
      description: CONTRIBUTORS ONLY. `lag_days` is required. `PUT` behaves identically.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - lag_days
              properties:
                lag_days:
                  type: integer
                  minimum: -365
                  maximum: 365
      responses:
        '200':
          description: The link
          content:
            application/json:
              schema:
                type: object
                required:
                - dependency
                - warnings
                properties:
                  dependency:
                    "$ref": "#/components/schemas/WorkspaceDependency"
                  warnings:
                    type: array
                    items:
                      type: string
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A viewer, Tasks is off, or the workspace is archived.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such link in this workspace.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: A missing or out-of-range `lag_days`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Workspace
      summary: Stop a to-do waiting on another
      description: CONTRIBUTORS ONLY. Dates are not changed.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Removed
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A viewer, Tasks is off, or the workspace is archived.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such link in this workspace.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/timeline":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    get:
      tags:
      - Workspace
      summary: The project's timeline
      description: |
        The desktop Timeline tab's data (PM-2a): the workspace's TOP-LEVEL to-dos
        that have a start or due date, done ones included and cancelled ones
        left out, in timeline order (first day, then due, then id) — each with
        its span (`starts_on`..`ends_on`, the days the calendar gives it; a
        milestone is its due day) and what it waits on. `dependencies` are the
        links among this page's rows; `conflicts` are the workspace's to-dos
        that start before what they wait on finishes; `undated_count` counts
        the open to-dos with no date. Members read; not gated on the plan.
        Requires the `enable_tasks` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
      responses:
        '200':
          description: The timeline
          content:
            application/json:
              schema:
                type: object
                required:
                - tasks
                - dependencies
                - conflicts
                - undated_count
                - total_count
                - meta
                properties:
                  tasks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceTimelineTask"
                  dependencies:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceDependency"
                  conflicts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceScheduleConflict"
                  undated_count:
                    type: integer
                  total_count:
                    type: integer
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a member, or Tasks is switched off.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/schedule":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
    post:
      tags:
      - Workspace
      summary: Preview or shift late to-dos
      description: |
        `mode: preview` (the default) returns the moves a shift would make and
        writes nothing. `mode: shift` makes them — CONTRIBUTORS ONLY: every to-do
        that starts before what it waits on finishes moves LATER, whole (start
        and due by the same days), in dependency order so a move cascades down
        the chain; all the moves or none. Nothing is ever moved earlier, and a
        finished, cancelled or already-started to-do is not moved. A moved
        to-do's open, not-started, dated subtasks move with it by the same days
        (each move's `subtasks`). Both modes need the `write:workspace` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum:
                  - preview
                  - shift
                  default: preview
      responses:
        '200':
          description: The moves (previewed or made) and the conflicts left
          content:
            application/json:
              schema:
                type: object
                required:
                - mode
                - moves
                - conflicts
                properties:
                  mode:
                    type: string
                    enum:
                    - preview
                    - shift
                  moves:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceScheduleMove"
                  conflicts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceScheduleConflict"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a member, Tasks is off, or (to shift) a viewer or an archived
            workspace.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: An unknown `mode`, or a move was refused (nothing was moved).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/check-ins":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List recurring check-ins
      description: |
        The workspace's ACTIVE recurring check-ins — the questions the app asks
        members on a cadence — with the next scheduled run for each.

        Requires the business-level `enable_check_ins` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Active check-ins
          content:
            application/json:
              schema:
                type: object
                required:
                - check_ins
                - total_count
                - meta
                properties:
                  check_ins:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceCheckIn"
                  total_count:
                    type: integer
                    description: Active check-ins on this workspace, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/check-ins/my-drafts":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: My AI-drafted check-in responses awaiting approval
      description: |
        The CALLER's own AI-drafted check-in responses for this workspace that
        have not been committed yet — the queue behind the "review your draft"
        prompt on the web and mobile surfaces. Never another user's drafts.

        Approve one with the `.../responses/{response_id}/approve` endpoint;
        it then disappears from this list. Only check-ins that take a written
        answer are drafted; one that asks for a typed answer (`answer_type`
        other than `text`) never appears here.

        Requires the business-level `enable_check_ins` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: The caller's pending drafts
          content:
            application/json:
              schema:
                type: object
                required:
                - drafts
                - total_count
                - meta
                properties:
                  drafts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceCheckInDraft"
                  total_count:
                    type: integer
                    description: The caller's pending drafts here, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/check-ins/responses/{response_id}/approve":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: response_id
      in: path
      required: true
      description: The draft check-in response id, from `my-drafts`.
      schema:
        type: integer
    post:
      tags:
      - Workspace
      summary: Approve an AI-drafted check-in response
      description: |
        Commits one of the caller's AI drafts as their real check-in response,
        optionally replacing the text first. CONTRIBUTORS ONLY.

        **Only your own draft.** Someone else's is `403`, and a draft that
        lives in a different workspace is `404` even if you are a member of
        both.

        Requires the business-level `enable_check_ins` toggle. Check-ins can
        additionally be turned off for a SINGLE workspace by its owner; that
        refusal comes back as `422` with the reason, distinct from the
        business-level `403`.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                response_text:
                  type: string
                  description: Replaces the drafted text before committing. Omit to
                    commit the draft as written.
      responses:
        '200':
          description: The committed response
          content:
            application/json:
              schema:
                type: object
                required:
                - response
                properties:
                  response:
                    type: object
                    properties:
                      id:
                        type: integer
                      responded_at:
                        type: string
                        format: date-time
                        description: Set once the draft is committed.
                      response_text:
                        type: string
                      answer_value:
                        type: string
                        nullable: true
                        description: A typed answer's value — null for a draft, which
                          is always a written answer.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or check-ins are disabled, the caller is not a member,
            the caller is a read-only viewer, or the draft belongs to another user
            (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or no such draft in THIS workspace (error
            code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: 'The draft was not committed (error code `validation_failed`).
            `error.message` carries the service''s own reason(s), joined, and `error.details`
            the individual messages — read them verbatim; most are the caller''s to
            act on: an empty response ("Your response can''t be blank. Edit the draft
            or delete it instead of approving an empty response."), a content-moderation
            refusal of `response_text`, a record validation such as "Response text
            is too long (maximum is 10000 characters)", check-ins turned off for this
            specific workspace, a draft whose check-in has since started asking for
            a typed answer, or a non-string `response_text` ("response_text must be
            a string.", with no `details`). An internal fault answers the fixed line
            "Couldn''t approve this check-in response. Please try again." — the detail
            is logged server-side, and that one is worth retrying.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/events":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List schedule events
      description: |
        The workspace's schedule. `upcoming` (the default) returns events that
        have not started yet, soonest first; `upcoming=false` returns PAST
        events, most recent first — it is a genuine "past events" tab, not the
        same list reversed, and `total_count` counts only the half you asked
        for.

        `internal_only` is carried on every row: `location` is where meeting
        links live, so an integration building a client-facing calendar needs
        to know which entries must not leave the company.

        Requires the business-level `enable_schedule` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: upcoming
        in: query
        required: false
        description: '`true` (the default) for events yet to start; `false` for past
          events. Only a recognised false value — `false`, `0`, `"false"`, `"0"`,
          `"f"` or `"off"` (or their all-upper-case spellings) — selects past events;
          any other value, including an unrecognised string, behaves as the default
          (upcoming).'
        schema:
          type: boolean
          default: true
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Schedule events
          content:
            application/json:
              schema:
                type: object
                required:
                - events
                - total_count
                - meta
                properties:
                  events:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceEvent"
                  total_count:
                    type: integer
                    description: Events in the requested half (upcoming OR past),
                      across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/events/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The event id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One schedule event
      description: |
        A single event on this workspace's schedule.

        Requires the business-level `enable_schedule` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The event
          content:
            application/json:
              schema:
                type: object
                required:
                - event
                properties:
                  event:
                    "$ref": "#/components/schemas/WorkspaceEvent"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or event (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/hill-charts":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List hill charts
      description: |
        The workspace's ACTIVE hill charts — each a named scope and its
        position on the "figuring it out / making it happen" curve.

        When the Workspace agent has proposed a new position but nobody has
        accepted it yet, `ai_proposal_pending` is true and
        `ai_proposed_position` carries the suggestion; `position` still holds
        the human-set value.

        Requires the business-level `enable_hill_charts` toggle.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Active hill charts
          content:
            application/json:
              schema:
                type: object
                required:
                - hill_charts
                - total_count
                - meta
                properties:
                  hill_charts:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceHillChart"
                  total_count:
                    type: integer
                    description: Active hill charts on this workspace, across all
                      pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No workspace with that id or slug is reachable by the caller
            (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/hill-charts/{id}":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    - name: id
      in: path
      required: true
      description: The hill chart id.
      schema:
        type: integer
    get:
      tags:
      - Workspace
      summary: One hill chart
      description: |
        A single hill chart. Unlike the list, this reaches ARCHIVED charts too
        — `status` says which.

        Requires the business-level `enable_hill_charts` toggle.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The hill chart
          content:
            application/json:
              schema:
                type: object
                required:
                - hill_chart
                properties:
                  hill_chart:
                    "$ref": "#/components/schemas/WorkspaceHillChart"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace or hill chart (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/reactions":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    get:
      tags:
      - Workspace
      summary: List reactions on one item
      description: |
        Every reaction on ONE reactable item, oldest first, with the reacting
        user on each row. Both `reactable_type` and `reactable_id` are
        required — this lists the reactions on a specific message, comment or
        task, not the workspace's reactions in bulk.

        The item must live in THIS workspace; one from another workspace is
        `404` even if the caller can see it elsewhere.

        Requires the business-level `enable_reactions` toggle.
      security:
      - BearerAuth: []
      parameters:
      - name: reactable_type
        in: query
        required: true
        description: The kind of item to read reactions for.
        schema:
          type: string
          enum:
          - WorkspaceMessage
          - WorkspaceMessageComment
          - Task
      - name: reactable_id
        in: query
        required: true
        description: The id of the item, which must belong to this workspace.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        required: false
        description: Rows per page. Maximum 200.
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
      - name: limit
        in: query
        required: false
        description: Alias for `per_page`, kept because it is what these endpoints
          shipped with. `per_page` wins when both are sent. A blank or non-numeric
          value falls back to the endpoint's default.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Reactions on the item
          content:
            application/json:
              schema:
                type: object
                required:
                - reactions
                - total_count
                - meta
                properties:
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WorkspaceReaction"
                  total_count:
                    type: integer
                    description: Reactions on this item, across all pages.
                  meta:
                    "$ref": "#/components/schemas/WorkspacePaginationMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Workspace app is not enabled for the business or not accessible
            to the caller, the caller is not a member of this workspace, or the canvas
            section this endpoint serves is switched off for the business (error code
            `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or `reactable_type` is not one of the allowed
            types, or no item with that id in this workspace (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/workspace/workspaces/{workspace_id}/reactions/toggle":
    parameters:
    - name: workspace_id
      in: path
      required: true
      description: The workspace's numeric id OR its slug.
      schema:
        type: string
      example: '42'
    post:
      tags:
      - Workspace
      summary: Add or remove a reaction
      description: |
        Adds or removes the caller's reaction with ONE emoji on one item.
        `reacted` in the response is `true` when the reaction is now ON,
        `false` when it is off; `action` says the same as `added` / `removed`,
        and `counts` returns the item's full per-emoji tally so a client can
        re-render without a second call. CONTRIBUTORS ONLY.

        - body `{ reactable_type, reactable_id, emoji }` → TOGGLE: adds the
          reaction if absent, removes it if present.
        - body `{ reactable_type, reactable_id, emoji, reacted: true|false }`
          → SET the reaction to that exact state. `true` ensures the caller's
          reaction with that emoji exists (no change if it already does);
          `false` ensures it does not (no change if it already doesn't).

        **Send `reacted` to make the call safe to retry.** A bare toggle is
        NOT retry-safe: the server adds the reaction, the response is lost,
        the client retries, and the retry removes what was just added. This
        endpoint does not honour `Idempotency-Key`; a request that states the
        state it wants is idempotent by construction. Same contract as
        `POST /broadcasts/{broadcast_id}/reactions/toggle`.

        The emoji must be in the item's allowed set; anything else is `422`.
        Posts, replies and to-dos offer the company's reaction palette (its admin's choice from the one reaction catalog, the whole catalog unless narrowed).
        A reaction the caller already holds from outside it is still accepted,
        so it can be removed.
        Concurrent toggles are safe — a duplicate insert is treated as an add.

        Requires the business-level `enable_reactions` toggle.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reactable_type
              - reactable_id
              - emoji
              properties:
                reactable_type:
                  type: string
                  enum:
                  - WorkspaceMessage
                  - WorkspaceMessageComment
                  - Task
                reactable_id:
                  type: integer
                  description: The id of the item, which must belong to this workspace.
                emoji:
                  type: string
                  description: One emoji from the item's allowed set.
                  example: "\U0001F44D"
                reacted:
                  type: boolean
                  description: 'Optional: the state you want. `true` → the caller''s
                    reaction with this emoji is ON afterwards; `false` → it is OFF.
                    Already in that state is a no-op that still answers `200`, so
                    a retry is safe. Cast as a boolean, so JSON booleans and the usual
                    string spellings (`"true"`, `"false"`, `"1"`, `"0"`) work. Absent,
                    `null` or `""` → toggle, which is NOT safe to retry.'
                'on':
                  type: boolean
                  description: Alias for `reacted`, read only when `reacted` is absent.
      responses:
        '200':
          description: The toggle result and the item's new tally
          content:
            application/json:
              schema:
                type: object
                required:
                - action
                - reacted
                - counts
                properties:
                  action:
                    type: string
                    enum:
                    - added
                    - removed
                    description: "`added` when the reaction is now on, `removed` when
                      it is now off — including when a `reacted` request found it
                      already in that state and changed nothing."
                  reacted:
                    type: boolean
                    description: True when the caller's reaction with this emoji is
                      now ON; false when it is off.
                  counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: Reaction count per emoji on this item, after the
                      call.
                    example:
                      "\U0001F44D": 3
                      "\U0001F389": 1
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The app or reactions are disabled, the caller is not a member,
            or the caller is a read-only viewer (error code `forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: No such workspace, or `reactable_type` is not one of the allowed
            types, or no item with that id in this workspace (error code `not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: The emoji is not in the item's allowed set (error code `validation_failed`,
            "Emoji not allowed."). Nothing changed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/ideas":
    post:
      tags:
      - Ideas
      summary: Post a new idea
      description: |
        Creates an idea — the native mirror of the web composer
        (`Apps::Ideas::IdeasController#create`), sharing
        `Ideas::IdeaCreationService` and `Ideas::AttachmentScreener` so the two
        surfaces produce identical rows.

        **Side effects, all matching the web:**
        * the idea lands in the workspace's **entry** lifecycle stage (a new idea is
          never dropped straight into Reviewing/Planned);
        * the **author auto-votes**, so a brand-new idea comes back with
          `vote_count: 1` and `has_voted: true` (the web's *"Idea posted — you're the
          first vote!"*);
        * `description_html` is derived from the plain text (escaped first, then
          formatted, so line breaks survive and any markup the author typed stays
          inert text);
        * an audit entry is written and the review panel is notified in the
          background.

        **Attachments are gated.** `files[]` / `file_signed_ids[]` are accepted only
        while **"Allow file attachments"** is ON for the workspace. When it is OFF the
        files are **dropped and the idea is still created** — the web behaves the same
        way (its composer simply renders no file field) — and the drop is **named
        per file in `attachment_errors` and repeated in `warnings`**. Accepted files
        are screened against a size cap and a sniffed-content-type allowlist; anything
        dropped is reported the same way. `attachment_errors` is always present (empty
        when everything attached) because a silently missing attachment is the worst
        outcome.

        **`warnings` names every field this workspace's settings discarded.** A 2xx
        that dropped part of the write says so: a file the attachments toggle refused,
        a `voting_closes_on` the close-date toggle refused, a `campaign_id` the
        campaigns toggle refused. The key is omitted entirely when nothing was
        dropped, so its presence is the signal. `GET /api/v1/ideas/config` advertises
        the same toggles if a client would rather not send the field at all.

        **Authorization** is the workspace's *"Who can post ideas"* audience
        (`submit_audience`) — everyone, or only one configured group. Reading the
        Ideas app is not enough: a user can browse ideas and still be outside the
        posting audience (`403 forbidden`).

        **Duplicate detection** mirrors the web composer. Before creating, the title
        is matched against existing ideas (the SAME matcher — `Ideas::DuplicateFinder`
        — behind the web's nudge and its typeahead). If it looks like one or more
        existing ideas and the caller hasn't confirmed, the endpoint answers
        **`409`** with the matches — `{ duplicates_found: true, message,
        duplicates: [{ id, title, vote_count, has_voted, stage, decided, voting_closed }] }` — and **creates
        nothing**. Show them ("add your vote, or post anyway" — a vote only on a
        match that is not `decided`, not `voting_closed` and not already
        `has_voted`), then re-POST with
        **`confirm_duplicates: true`** to post anyway. Detection runs only for an
        otherwise-valid idea, so a missing field still answers `422`, not `409`.

        Responds **201** with the SAME canonical idea object
        `GET /api/v1/ideas/{id}` returns.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        description: "`application/json` for a text-only idea; `multipart/form-data`
          when posting `files[]`."
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - description
              properties:
                title:
                  type: string
                  maxLength: 200
                  description: Required. Surrounding whitespace is stripped.
                  example: Dark mode for the mobile app
                description:
                  type: string
                  description: Required — the model validates its presence on create.
                  example: Please add a dark theme; the white background is rough
                    on night shift.
                campaign_id:
                  type: integer
                  nullable: true
                  description: Optional. Honoured only while the Campaigns feature
                    is on — silently ignored when it is off, so a client keeps working
                    if the workspace later disables campaigns. Must belong to this
                    business and still be open, else `422`.
                  example: 12
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                  description: Optional. Honoured only while the voting-close-date
                    feature is on; ignored otherwise. Must be on or before the campaign's
                    close date.
                  example: '2026-09-30'
                confirm_duplicates:
                  type: boolean
                  default: false
                  description: Set `true` to skip duplicate detection and post even
                    when a similar idea already exists (the web's "post anyway").
                    When omitted/false, a title that matches existing ideas answers
                    `409` with the matches instead of creating.
                  example: false
          multipart/form-data:
            schema:
              type: object
              required:
              - title
              - description
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: string
                campaign_id:
                  type: integer
                  nullable: true
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                confirm_duplicates:
                  type: boolean
                  default: false
                  description: Set `true` to skip duplicate detection and post anyway
                    (the web's "post anyway"). Otherwise a matching title answers
                    `409`.
                files[]:
                  type: array
                  description: Files to attach. Accepted ONLY while "Allow file attachments"
                    is ON; otherwise dropped (the idea is still created). Screened
                    against a size cap and a sniffed content-type allowlist (images,
                    PDF, Word/Excel/PowerPoint, plain text).
                  items:
                    type: string
                    format: binary
                file_signed_ids[]:
                  type: array
                  description: 'Alternative to `files[]` for clients that direct-upload
                    first: ActiveStorage signed ids. Re-screened server-side, and
                    an already-attached blob is rejected, so a signed id cannot be
                    replayed to steal another record''s file.'
                  items:
                    type: string
      responses:
        '201':
          description: Idea created
          content:
            application/json:
              schema:
                type: object
                required:
                - idea
                - attachment_errors
                properties:
                  idea:
                    type: object
                    description: The created idea, in the SAME shape `GET /api/v1/ideas/{id}`
                      returns — one query object and one serializer back both, so
                      the two cannot drift. See that endpoint for the full field list.
                      Note `vote_count` is 1 and `has_voted` is true on a fresh create,
                      because the author auto-votes.
                    properties:
                      id:
                        type: integer
                        example: 91
                      title:
                        type: string
                        example: Dark mode for the mobile app
                      description:
                        type: string
                        nullable: true
                      description_html:
                        type: string
                        nullable: true
                      vote_count:
                        type: integer
                        example: 1
                      has_voted:
                        type: boolean
                        example: true
                      stage:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 1
                          label:
                            type: string
                            example: New
                  attachment_errors:
                    type: array
                    description: One human-readable reason per file that was NOT attached
                      (wrong type, over the size cap, or the workspace's "Allow file
                      attachments" setting being off). Always present; empty when
                      everything attached. Never fatal — the idea is created either
                      way.
                    items:
                      type: string
                    example:
                    - payload.zip is not a supported file type and wasn't attached.
                  warnings:
                    type: array
                    description: One sentence per field this workspace's settings
                      discarded — dropped files, a refused `voting_closes_on`, a refused
                      `campaign_id`. OMITTED when nothing was dropped, so the key's
                      presence is the signal that the 2xx is a partial write.
                    items:
                      type: string
                    example:
                    - '"Allow ideas to have a voting close date" is off for this workspace
                      — voting_closes_on was ignored and no close date was set.'
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is outside the workspace's submit audience (error
            code `forbidden`), or the Ideas app is not accessible to them (`access_denied`).
        '409':
          description: 'The title matches one or more existing ideas and `confirm_duplicates`
            was not set — the matches are returned and **nothing was created**. Re-POST
            with `confirm_duplicates: true` to post anyway.'
          content:
            application/json:
              schema:
                type: object
                required:
                - duplicates_found
                - message
                - duplicates
                properties:
                  duplicates_found:
                    type: boolean
                    example: true
                  message:
                    type: string
                    description: Human-readable summary, e.g. the web's nudge headline.
                    example: 2 similar ideas already exist
                  duplicates:
                    type: array
                    description: The matching ideas, most-likely first (max 3). Just
                      enough to render the "add your vote, or post anyway" panel —
                      offer the vote only on a match whose `decided`, `voting_closed`
                      and `has_voted` are all false.
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - vote_count
                      - has_voted
                      - voting_closed
                      properties:
                        id:
                          type: integer
                          example: 74
                        title:
                          type: string
                          example: Dark mode for the mobile app
                        vote_count:
                          type: integer
                          description: The idea's current upvote count.
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the CALLING user has already upvoted
                            this idea.
                          example: false
                        stage:
                          type: string
                          nullable: true
                          description: The label of the lifecycle stage the match
                            is in.
                          example: Planned
                        decided:
                          type: boolean
                          description: True when the match is already in an Implemented-
                            or Declined-category stage, so a client should not offer
                            a vote on it.
                          example: false
                        voting_closed:
                          type: boolean
                          description: True once the match's voting close date has
                            passed — but only on a workspace where `close_date_enabled`
                            is true. `POST /ideas/{idea_id}/vote` answers `422 voting_closed`
                            on such a match, so a client should not offer a vote on
                            it.
                          example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '422':
          description: Validation failed (error code `invalid`) — a missing/blank
            title, a title over 200 characters, a missing description, or a campaign
            that is closed or belongs to another business. Also `no_entry_stage` when
            the workspace has no entry lifecycle stage configured. Also `content_blocked`
            when the workspace's content-moderation policy refuses the submitted text;
            the message is the policy's own and is safe to show the author verbatim.
  "/ideas/dashboard":
    get:
      tags:
      - Ideas
      summary: Ideas dashboard
      description: |
        The native-client mirror of the web Ideas dashboard
        (`Apps::IdeasController#show`). Every number and list is produced by the
        SAME query object the web view uses (`Ideas::DashboardStats`), so the two
        surfaces cannot drift.

        **Persona-independent.** Unlike some dashboards, the web Ideas dashboard
        applies NO persona / visibility / status filter — an idea has no
        draft/published state and is live the moment it is created. Admins,
        reviewers and regular members therefore receive the **identical**
        payload. There is no `is_admin` branch.

        **Sections** (each list is capped at 5):
        * `total_ideas` — business-scoped count of all ideas.
        * `count_by_stage` — one row per lifecycle stage in pipeline order
          (`position` asc), with `count` defaulting to 0 — exactly the per-stage
          tiles the web renders. The counts sum to `total_ideas` (every idea is
          in exactly one stage).
        * `top_voted` — highest-voted first (the `up_votes_count` counter cache;
          positive votes only). Fields: id, title, votes, stage.
        * `recently_added` — newest first by **`created_at`** (the date the web
          renders as "N ago"; there is no separate published/submitted date).
          Fields: id, title, creator_name (the author's full name), created_at.
        * `most_discussed` — most-commented first (all non-deleted
          `Platform::Comment` on the idea, threaded replies included).
          Zero-comment ideas are **dropped** from this section, matching the web,
          so it can contain fewer than 5 (or be empty) in a quiet tenant.
          Fields: id, title, comments_count.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - dashboard
                properties:
                  dashboard:
                    type: object
                    required:
                    - total_ideas
                    - count_by_stage
                    - top_voted
                    - recently_added
                    - most_discussed
                    properties:
                      total_ideas:
                        type: integer
                        description: Count of all ideas in the business.
                        example: 42
                      count_by_stage:
                        type: array
                        description: One row per lifecycle stage, in pipeline (position)
                          order. Counts sum to total_ideas.
                        items:
                          type: object
                          required:
                          - stage_id
                          - label
                          - count
                          properties:
                            stage_id:
                              type: integer
                              example: 1
                            label:
                              type: string
                              example: New
                            color:
                              type: string
                              description: Stage colour (hex).
                              example: "#6c757d"
                            count:
                              type: integer
                              description: Ideas currently in this stage (0 when empty).
                              example: 12
                      top_voted:
                        type: array
                        description: Up to 5 ideas, highest up-votes first.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - votes
                          - stage
                          properties:
                            id:
                              type: integer
                              example: 87
                            title:
                              type: string
                              example: Dark mode for the mobile app
                            votes:
                              type: integer
                              description: Positive-vote total (up_votes_count).
                              example: 34
                            stage:
                              type: object
                              required:
                              - id
                              - label
                              properties:
                                id:
                                  type: integer
                                  example: 2
                                label:
                                  type: string
                                  example: Reviewing
                      recently_added:
                        type: array
                        description: Up to 5 ideas, newest created_at first.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - creator_name
                          - created_at
                          properties:
                            id:
                              type: integer
                              example: 91
                            title:
                              type: string
                              example: Add SSO for contractors
                            creator_name:
                              type: string
                              description: The author's full name.
                              example: Dana Lee
                            created_at:
                              type: string
                              format: date-time
                              description: When the idea was created (rendered as
                                "N ago" on web).
                              example: '2026-07-31T06:01:58Z'
                      most_discussed:
                        type: array
                        description: Up to 5 ideas with the most comments (replies
                          included), highest first. Zero-comment ideas are excluded,
                          so this may be shorter than 5 or empty.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - comments_count
                          properties:
                            id:
                              type: integer
                              example: 87
                            title:
                              type: string
                              example: Dark mode for the mobile app
                            comments_count:
                              type: integer
                              description: Non-deleted comments + replies.
                              example: 9
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/config":
    get:
      tags:
      - Ideas
      summary: Ideas workspace configuration
      description: |
        The tenant's Ideas configuration **as this caller experiences it** — the
        native mirror of what every web Ideas screen reads from
        `IdeasAppConfigurable`, and of what an admin edits at **Apps ▸ Ideas ▸
        Settings**. One call, so a client can render the app the way this workspace
        is configured without probing endpoint by endpoint.

        **Persona-aware, not persona-branched.** Every caller receives the same
        keys. The two `can_*` booleans are resolved **for the caller** under the
        exact rule the write sites enforce, so a client can show the *Submit Idea*
        and *New Campaign* affordances precisely when the server would accept
        them. There is **no admin bypass** on either: an admin configures the
        audience, they don't override it.

        **Audiences** (`submit_audience`, `campaign_creators`) are each reported as
        `{ value, type, group }`:
        * `type: "all"` — everyone in the business may do it (the default, and what
          a blank setting means).
        * `type: "group"` — only members of `group` may. `group` is `null` when the
          saved group has since been deleted or belongs to another business; the
          runtime gate denies in that case, so `can_submit_idea` /
          `can_create_campaign` is `false` — **trust the `can_*` boolean**, never
          infer permission from `type`.

        **Review panels** (`idea_reviewers`, `campaign_reviewers`) are each
        `{ group, count, source }`:
        * `count` is the panel's FULL membership size. Neither panel **names anyone**
          — there is no `members` key and the shape does not change with
          `reviewer_names_visible`. Render "Reviewed by <group.name> (<count>)" from
          this, and call a roster endpoint for the actual people — both paginated,
          name-searchable, and gated on `reviewer_names_visible`:
          `GET /ideas/{idea_id}/reviewers` for a given idea's panel, and
          `GET /ideas/campaigns/{id}/reviewers` for a given campaign's.
        * `idea_reviewers.source` is `configured` when Settings picked the group, or
          `fallback` when no group is saved and ideas therefore route to the
          built-in **All Admins** group.
        * `campaign_reviewers.source` is `configured` when Settings picked a
          campaign panel, or `inherited` when it is blank — the Settings option
          "— Same as the Idea Reviewers group —", so the group echoes
          `idea_reviewers.group`. This is the panel a NEW campaign starts with;
          changing it never moves an existing campaign. Only someone allowed to
          choose a campaign's review panel (`can_assign_review_panel`, Ideas admins
          by default) may pick another one on a campaign.
        * `group` is `null` (with `count: 0`) only when nothing resolves at all —
          no saved group and no All Admins group to fall back to.

        **`campaign_options`** is the option data a "New Campaign" composer needs so
        it can only offer values `POST /ideas/campaigns` accepts: the authored icon
        set, the accent palette with its hex values, this workspace's selectable
        reviewer groups, and the defaults each field falls back to when omitted. The
        reviewer-group list is empty while `campaigns_enabled` is `false`.

        **Stages** is the complete lifecycle pipeline in order (`position`
        ascending). `id` is stable across a rename, so a client may cache stage ids;
        `name` is the admin-editable label; `category`
        (`entry`/`active`/`implemented`/`declined`) is what outcome metrics key off
        — never the id. `icon` is the stage's Font Awesome glyph name with **no `fa-`
        prefix**, DERIVED from the label and category (there is no icon column and
        admins never pick one) — the same glyph the web renders for that stage, so a
        native pipeline looks like the web one. See `IdeaLifecycleStage` for the exact
        derivation. A workspace that has never opened Ideas gets the shipped default
        pipeline seeded on first read, so this array is never empty.

        **`accent`** is the workspace's accent colour, shipped as its key **and** the
        five colour tokens every web Ideas screen renders from — tint your own chrome
        from those tokens rather than a client-side palette, which would drift from the
        web the moment a token is re-tuned. See the field below for the coercion rule.

        **Deliberately not included:** the web-chrome LAYOUT settings (feed density,
        feed layout — a native client lays out its own way), the per-event `notify_*`
        toggles (delivery-side, inert for a client), and `agent_enabled` (surfaced
        through the Ask AI plumbing).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Configuration retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - config
                properties:
                  config:
                    type: object
                    required:
                    - submit_audience
                    - can_submit_idea
                    - idea_reviewers
                    - scoring_enabled
                    - reviewer_names_visible
                    - vote_icon
                    - accent
                    - vote_change_allowed
                    - close_date_enabled
                    - attachments_enabled
                    - ai_assist_enabled
                    - campaigns_enabled
                    - campaign_creators
                    - can_create_campaign
                    - can_assign_review_panel
                    - campaign_reviewers
                    - campaign_options
                    - stages
                    properties:
                      submit_audience:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasAudience"
                        description: Who can post ideas — everyone, or one group.
                      can_submit_idea:
                        type: boolean
                        description: Whether THIS caller may submit an idea right
                          now, under the audience above. Authoritative — drive the
                          Submit affordance off this, not off `submit_audience.type`.
                        example: true
                      idea_reviewers:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasReviewPanel"
                        description: The workspace review panel every idea routes
                          to unless its campaign overrides it. `source` is `configured`
                          or `fallback`.
                      scoring_enabled:
                        type: boolean
                        description: RICE scoring (score pills, the Value · Effort
                          matrix, the "Highest Score on Top" sort). Default true.
                        example: true
                      reviewer_names_visible:
                        type: boolean
                        description: Whether reviewer identities may be shown. When
                          false, BOTH roster endpoints — `GET /ideas/{idea_id}/reviewers`
                          and `GET /ideas/campaigns/{id}/reviewers` — return 403 `reviewer_names_hidden`
                          — show only the panel's group name and count. This flag
                          does NOT change any shape in this payload (no panel here
                          names anyone either way); it exists so a client hides the
                          roster affordance instead of tapping into the 403.
                        example: true
                      vote_icon:
                        type: string
                        description: |
                          The configured upvote glyph. Always a value — an unset
                          setting falls back to `caret-up`.

                          The Settings picker offers `caret-up`, `chevron-up`,
                          `arrow-up`, `circle-up` and `thumbs-up`. This field echoes
                          what is STORED rather than re-validating it (the same rule
                          the web's `vote_icon` helper applies), so treat it as an
                          open string and fall back to `caret-up` for any name you
                          don't recognise.
                        example: caret-up
                      accent:
                        type: object
                        description: |
                          The workspace accent an admin picked in Settings — the colour
                          that tints buttons, active states and subtle backgrounds on
                          every Ideas screen. Tint your own chrome from these tokens so
                          the native app matches the web.

                          Shipped as the KEY **and** its five colour tokens, from the
                          one palette the web views render from. Do not hardcode a
                          palette keyed off `key` alone: the tokens can be re-tuned
                          server-side, and a client copy would silently drift to a
                          different shade than the web.

                          `key` is whitelist-coerced to the four authored accents, so a
                          workspace whose stored value is blank, retired or hand-edited
                          reports `blue` — key and tokens together, never a key you
                          cannot resolve to a colour.
                        required:
                        - key
                        - primary
                        - bright
                        - hover
                        - subtle
                        - subtle_text
                        properties:
                          key:
                            type: string
                            enum:
                            - blue
                            - forest
                            - purple
                            - sunset
                            example: forest
                          primary:
                            type: string
                            description: Buttons
                            links:
                            the main accented fill.:
                            example: "#2d6a4f"
                          bright:
                            type: string
                            description: A lighter variant for emphasis / highlights.
                            example: "#1B8751"
                          hover:
                            type: string
                            description: The pressed / hovered state of `primary`.
                            example: "#235640"
                          subtle:
                            type: string
                            description: Tinted background for chips
                            pills and active rows.:
                            example: "#d8efe3"
                          subtle_text:
                            type: string
                            description: Readable text colour ON a `subtle` background.
                            example: "#1f5e44"
                      vote_change_allowed:
                        type: boolean
                        description: Whether a member may remove their own upvote.
                          When false, `DELETE /ideas/{idea_id}/vote` is refused —
                          hide the un-vote affordance.
                        example: true
                      close_date_enabled:
                        type: boolean
                        description: Whether ideas may carry a voting close date.
                          The one opt-in setting — default **false**. While false,
                          `voting_closes_on` is never surfaced on an idea.
                        example: false
                      attachments_enabled:
                        type: boolean
                        description: Whether ideas may carry file attachments (comments
                          never do). Default true.
                        example: true
                      ai_assist_enabled:
                        type: boolean
                        description: Whether AI writing assistance (expand rough notes
                          into a draft) is offered on the compose form. Default true.
                        example: true
                      campaigns_enabled:
                        type: boolean
                        description: Whether idea campaigns exist for this workspace.
                          When false, hide the Campaigns section — `GET /ideas/campaigns`
                          returns 403 `campaigns_disabled` — and `can_create_campaign`
                          is false regardless of the audience.
                        example: true
                      campaign_creators:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasAudience"
                        description: Who can launch new campaigns — everyone, or one
                          group.
                      can_create_campaign:
                        type: boolean
                        description: Whether THIS caller may create a campaign right
                          now — the audience above AND `campaigns_enabled`.
                        example: true
                      can_assign_review_panel:
                        type: boolean
                        description: 'Whether THIS caller may choose a campaign''s
                          review panel, i.e. whether the panel select in a composer
                          is live for them. The workspace capability (default: Ideas
                          admins only), so a member who may CREATE a campaign commonly
                          may not APPOINT its panel — their campaign inherits `campaign_reviewers`.
                          When this is false, `campaign_options.reviewer_groups` is
                          empty for that reason rather than because there are no groups.'
                        example: true
                      campaign_reviewers:
                        allOf:
                        - "$ref": "#/components/schemas/IdeasReviewPanel"
                        description: The panel a NEW campaign pre-fills with. `source`
                          is `configured`, or `inherited` when it mirrors `idea_reviewers`.
                      campaign_options:
                        type: object
                        description: The option data a "New Campaign" composer needs,
                          so it can only offer values `POST /ideas/campaigns` accepts
                          — the same sets the web form draws its radio buttons and
                          select from.
                        properties:
                          icons:
                            type: array
                            description: The authored Font Awesome glyph names (no
                              `fa-` prefix) accepted as `icon`. Anything else is rejected
                              with `422 invalid_icon`.
                            items:
                              type: string
                            example:
                            - bullhorn
                            - rocket
                            - lightbulb
                          colors:
                            type: array
                            description: The accent palette. `key` is what you send
                              as `color`; the three hex values let a client render
                              the card and its chips without shipping the palette.
                            items:
                              type: object
                              properties:
                                key:
                                  type: string
                                  example: forest
                                hex:
                                  type: string
                                  example: "#2d6a4f"
                                subtle:
                                  type: string
                                  example: "#d8efe3"
                                subtle_text:
                                  type: string
                                  example: "#1f5e44"
                          reviewer_groups:
                            type: array
                            description: The groups this workspace can route a campaign
                              to, name ascending — the same list the web form's "Campaign
                              reviewers" select offers. Empty while `campaigns_enabled`
                              is false (there is no composer to render then).
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 7
                                name:
                                  type: string
                                  example: Product Council
                          default_icon:
                            type: string
                            description: Applied when `icon` is omitted.
                            example: bullhorn
                          default_color:
                            type: string
                            description: Applied when `color` is omitted.
                            example: blue
                          default_reviewer_group_id:
                            type: integer
                            nullable: true
                            description: The panel applied when `reviewer_group_id`
                              is omitted entirely (the workspace's configured campaign
                              panel). `null` means a campaign created without one
                              inherits the workspace default panel at read time.
                            example: 7
                      stages:
                        type: array
                        description: The complete lifecycle pipeline, in order (`position`
                          ascending). Never empty. Same stage shape `PATCH /ideas/{idea_id}/stage`
                          reports, so this list can populate a stage picker and its
                          result parsed identically.
                        items:
                          "$ref": "#/components/schemas/IdeaLifecycleStage"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/ai_draft":
    post:
      tags:
      - Ideas
      summary: Draft an idea description with AI
      description: |
        The native-client mirror of the web "Draft with AI" button on the idea
        compose form (`POST /apps/ideas/list/ai_draft`). Both call the SAME
        `Ideas::AiDraftService#draft_description`, so the two surfaces cannot drift.
        The campaign-brief equivalent is `POST /api/v1/ideas/campaigns/ai_brief`.

        **WRITES NOTHING.** It returns text for the caller to place in the
        description field; posting the idea is still `POST /api/v1/ideas`. Safe to
        call repeatedly, and each call re-drafts.

        **Both inputs are optional.** `title` is what the author has typed so far.
        The notes the author already wrote are read from `description`, falling back
        to `notes` — send either. Whatever was sent is echoed back as `prior_text`,
        so a client can offer "restore what I had" after replacing the field with a
        draft rather than losing the author's own words.

        **`fallback` is part of the contract, not decoration.** The drafting service
        never raises: when the LLM is unavailable it returns a locally-composed
        outline and sets `fallback: true`, with `notice` carrying the "AI is
        unavailable right now" wording. Surface `notice` as-is rather than presenting
        a fallback outline as a finished AI draft.

        **Gating** — two 403s a client should treat differently:
        * `ai_assist_disabled` — the workspace's AI writing-assistance setting is
          off. Reported by `GET /api/v1/ideas/config` as `ai_assist_enabled: false`,
          so hide the sparkles control rather than discovering this by tapping.
        * `forbidden` — AI assist is on, but this caller is outside the workspace's
          submit audience. The same gate `POST /api/v1/ideas` applies, so anyone who
          cannot post an idea cannot draft one either.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  nullable: true
                  description: The idea title typed so far. Optional.
                  example: Dark mode for the mobile app
                description:
                  type: string
                  nullable: true
                  description: The notes the author has already written, used as the
                    seed for the draft. Takes precedence over `notes`.
                  example: hard to read on the floor at night
                notes:
                  type: string
                  nullable: true
                  description: Alias for `description`, read only when `description`
                    is absent or blank. Present because the web form posts this name.
      responses:
        '200':
          description: A draft description. Nothing was created or modified.
          content:
            application/json:
              schema:
                type: object
                required:
                - draft
                - prior_text
                properties:
                  draft:
                    type: object
                    required:
                    - text
                    - fallback
                    - notice
                    properties:
                      text:
                        type: string
                        description: The drafted description, for the description
                          field.
                        example: On night shift the screen is hard to read…
                      fallback:
                        type: boolean
                        description: True when the LLM was unavailable and `text`
                          is a locally composed outline rather than an AI draft. Badge
                          it.
                        example: false
                      notice:
                        type: string
                        description: The message to show alongside the draft. Differs
                          by `fallback` — do not hardcode either wording client-side.
                        example: Drafted with AI — edit freely before posting.
                  prior_text:
                    type: string
                    description: The notes that were sent in, echoed back so a client
                      can offer to restore them. Empty string when nothing was sent.
                    example: hard to read on the floor at night
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: AI writing assistance is off for the workspace (error code
            `ai_assist_disabled`), the caller is outside the submit audience (`forbidden`),
            or the Ideas app is not accessible to them (`access_denied`).
  "/ideas/list":
    get:
      tags:
      - Ideas
      summary: Ideas feed ("All Ideas")
      description: |
        The native-client mirror of the web "All Ideas" feed
        (Apps::Ideas::IdeasController#index). A paginated, filtered, ordered list
        of ideas backed by the SAME query object the web uses
        (::Ideas::FeedListQuery), so the two surfaces can't drift.

        **Filter** (`filter`, one flat dimension, default `all`):
        * `all` — every idea in the business.
        * `my`  — ideas the caller authored (no stage sub-filter; the web's
          per-stage "My Ideas" facet is intentionally omitted here).
        * `<stage_id>` — every idea in that lifecycle stage. Use a `stage_id`
          from `counts.by_stage`. An unknown/foreign value falls back to `all`.

        **Sort** (`sort`, default `top`; the second level is always `created_at`
        DESC, with `id` DESC as a stable tiebreak):
        * `top`       — Most Voted on Top (`vote_count` desc)
        * `myvoted`   — My Votes on Top (ideas the caller upvoted first)
        * `new`       — Newest on Top (`created_at` desc)
        * `discussed` — Most Discussed on Top (comment count desc)
        * `score`     — Highest Score on Top (`rice_score` desc). When RICE
          scoring is turned off for the workspace this degrades to `top`
          (mirroring the web, which hides the option), and `sort` echoes `top`.

        **Counts** are filter-blind — `counts.all`, `counts.my` and one
        `counts.by_stage` row per lifecycle stage (position order, count 0 when
        empty) always reflect the whole business, so the client can badge every
        filter chip. `meta.total_count`, by contrast, reflects the ACTIVE filter
        (it is what pagination is over).

        **Description** — every row carries `description`, the idea's plain-text
        body, on EVERY filter and sort. It is the same field (same column) that
        `GET /ideas/search` and a campaign's idea list return, so one parser handles
        every list surface, and it is NOT truncated — a client renders whatever
        snippet its card design needs. This costs no extra query: the body is a
        plain column on the row the feed already loads.

        **Voting close date** — each idea carries `voting_closes_on` (ISO date)
        ONLY when the tenant has enabled the voting-close-date feature AND that
        idea has a date set; the key is omitted otherwise. Same gate the web uses
        to show the "Voting closes …" pill. Every row also carries `voting_closed`
        — true once that date has passed in the workspace's time zone, always false
        while the feature is off — the same verdict `POST /ideas/{idea_id}/vote`
        gives.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: all (default) | my | <stage_id>. An unknown value falls back
          to all.
        schema:
          type: string
          default: all
          example: all
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - top
          - myvoted
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Ideas feed retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - ideas
                - active_filter
                - sort
                - counts
                - meta
                properties:
                  active_filter:
                    type: string
                    description: The filter actually applied (echoes an unknown value
                      back as `all`).
                    example: all
                  sort:
                    type: string
                    description: The sort actually applied (`score` echoes `top` when
                      scoring is off).
                    example: top
                  ideas:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - description
                      - stage
                      - creator
                      - comments_count
                      - created_at
                      - vote_count
                      - has_voted
                      - voting_closed
                      properties:
                        id:
                          type: integer
                          example: 91
                        title:
                          type: string
                          example: Add SSO for contractors
                        description:
                          type: string
                          nullable: true
                          description: 'The idea''s plain-text body, on EVERY filter
                            (`all` / `my` / `<stage_id>`) and every sort — the same
                            field, from the same column, that `GET /ideas/search`
                            and a campaign''s idea list return. Not truncated: a client
                            renders its own snippet. Null only for an idea whose body
                            is genuinely empty.'
                          example: We should let contractors sign in with SSO.
                        stage:
                          type: object
                          description: The idea's current lifecycle stage.
                          properties:
                            id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            color:
                              type: string
                              description: The stage's whitelist-coerced hex color.
                              example: "#2e63b3"
                        creator:
                          type: object
                          properties:
                            id:
                              type: integer
                              nullable: true
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              description: The author's full name.
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL (a ui-avatars initial
                                tile when the user has no photo).
                              example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                        comments_count:
                          type: integer
                          description: Non-deleted comments + replies.
                          example: 4
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-07-31T06:01:58Z'
                        vote_count:
                          type: integer
                          description: Positive votes (up_votes_count counter cache).
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the calling user has upvoted this idea.
                          example: true
                        voting_closed:
                          type: boolean
                          description: True once the idea's voting close date has
                            passed — but only on a workspace where `close_date_enabled`
                            is true. A workspace that has the voting-close-date feature
                            off never reports a closed idea and never refuses a vote,
                            and `voting_closes_on` is withheld. The date ends at midnight
                            in the WORKSPACE's time zone, so draw the padlock from
                            this flag rather than by comparing `voting_closes_on`
                            with the device's day.
                          example: false
                        can_vote:
                          type: boolean
                          description: 'Whether this request may press the upvote
                            at all: false once `voting_closed`, and for an API token
                            without write:ideas, which `POST` / `DELETE /ideas/{idea_id}/vote`
                            ask (a signed-in session always holds it). Taking a vote
                            back also needs `vote_change_allowed`.'
                          example: true
                        rice_score:
                          type: integer
                          nullable: true
                          description: Cached RICE score; null when not scored. OMITTED
                            entirely while RICE scoring is off for the workspace.
                          example: 42
                        rice_score_band:
                          type: object
                          description: 'The colour band `rice_score` renders as, so
                            a client paints the score pill the same way the web does
                            without hardcoding our ranges. `tier` is the stable key
                            to switch on: `high` (>= 700), `medium` (>= 300), `low`
                            below that, `none` when the idea is awaiting its first
                            score. Travels with `rice_score` and is OMITTED under
                            the same gate (RICE scoring off for the workspace).'
                          properties:
                            tier:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
                              - none
                              example: high
                            label:
                              type: string
                              description: Human label for the band.
                              example: High
                            color:
                              type: string
                              description: Foreground hex the web uses.
                              example: "#146c43"
                            background:
                              type: string
                              description: Background hex the web uses; `transparent`
                                for the `none` band.
                              example: "#d1f0e0"
                        voting_closes_on:
                          type: string
                          format: date
                          description: The idea's voting close date (ISO `YYYY-MM-DD`).
                            Present ONLY when the tenant has the voting-close-date
                            feature enabled (`enable_close_date`) AND this idea has
                            a date set — the key is OMITTED otherwise (not null).
                            Mirrors the web, which shows the "Voting closes …" pill
                            under both conditions.
                          example: '2026-08-15'
                  counts:
                    type: object
                    description: Filter-blind badge counts for the whole business.
                    required:
                    - all
                    - my
                    - by_stage
                    properties:
                      all:
                        type: integer
                        description: All ideas in the business.
                        example: 128
                      my:
                        type: integer
                        description: Ideas the caller authored.
                        example: 7
                      by_stage:
                        type: array
                        description: One row per lifecycle stage, in position order
                          (count 0 when empty).
                        items:
                          type: object
                          required:
                          - stage_id
                          - name
                          - count
                          properties:
                            stage_id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            count:
                              type: integer
                              example: 14
                  meta:
                    type: object
                    description: Pagination over the ACTIVE filter.
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the active filter.
                        example: 128
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 7
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/search":
    get:
      tags:
      - Ideas
      summary: Search ideas
      description: |
        Free-text search over the Ideas feed — the native-client mirror of the
        web search box ("Ideas" bottom-nav → search → type). Backed by the SAME
        query object the list + web feed use (`::Ideas::FeedListQuery`, given a
        search term), so search and list agree on matching, ordering, counting
        and preloading.

        **`q`** is matched case-insensitively against the idea **title** and
        **description** (LIKE wildcards in the term are escaped). A blank/absent
        `q` returns the full feed (browse), matching the web, whose empty search
        box shows every idea.

        **Sort** (`sort`, default `top`; second level always `created_at` DESC,
        `id` DESC tiebreak): `top` (Most Voted) | `myvoted` | `new` | `discussed`
        | `score` (RICE; degrades to `top` and echoes `top` when scoring is off).

        Each row is the SAME card the feed serializes PLUS **`description`** (the
        plain-text body). `voting_closes_on` is present ONLY when the tenant has
        the voting-close-date feature enabled AND that idea has a date set;
        `voting_closed` is on every row (always false while the feature is off).
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Search term (title + description, case-insensitive). Blank returns
          the full feed.
        schema:
          type: string
          example: single sign-on
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - top
          - myvoted
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Search results retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - ideas
                - query
                - sort
                - meta
                properties:
                  query:
                    type: string
                    description: The search term actually applied (echoes `q`, trimmed).
                    example: single sign-on
                  sort:
                    type: string
                    description: The sort actually applied (`score` echoes `top` when
                      scoring is off).
                    example: top
                  ideas:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - description
                      - stage
                      - creator
                      - comments_count
                      - created_at
                      - vote_count
                      - has_voted
                      - voting_closed
                      properties:
                        id:
                          type: integer
                          example: 91
                        title:
                          type: string
                          example: Add SSO for contractors
                        description:
                          type: string
                          nullable: true
                          description: The idea's plain-text body.
                          example: We should let contractors sign in with SSO.
                        stage:
                          type: object
                          description: The idea's current lifecycle stage.
                          properties:
                            id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            color:
                              type: string
                              description: The stage's whitelist-coerced hex color.
                              example: "#2e63b3"
                        creator:
                          type: object
                          properties:
                            id:
                              type: integer
                              nullable: true
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              description: The author's full name.
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL (a ui-avatars initial
                                tile when the user has no photo).
                              example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                        comments_count:
                          type: integer
                          description: Non-deleted comments + replies.
                          example: 4
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-07-31T06:01:58Z'
                        vote_count:
                          type: integer
                          description: Positive votes (up_votes_count counter cache).
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the calling user has upvoted this idea.
                          example: true
                        voting_closed:
                          type: boolean
                          description: True once the idea's voting close date has
                            passed — but only on a workspace where `close_date_enabled`
                            is true. A workspace that has the voting-close-date feature
                            off never reports a closed idea and never refuses a vote,
                            and `voting_closes_on` is withheld. The date ends at midnight
                            in the WORKSPACE's time zone, so draw the padlock from
                            this flag rather than by comparing `voting_closes_on`
                            with the device's day.
                          example: false
                        can_vote:
                          type: boolean
                          description: 'Whether this request may press the upvote
                            at all: false once `voting_closed`, and for an API token
                            without write:ideas, which `POST` / `DELETE /ideas/{idea_id}/vote`
                            ask (a signed-in session always holds it). Taking a vote
                            back also needs `vote_change_allowed`.'
                          example: true
                        rice_score:
                          type: integer
                          nullable: true
                          description: Cached RICE score (the idea's score); null
                            when not scored. OMITTED entirely while RICE scoring is
                            off for the workspace.
                          example: 42
                        rice_score_band:
                          type: object
                          description: 'The colour band `rice_score` renders as, so
                            a client paints the score pill the same way the web does
                            without hardcoding our ranges. `tier` is the stable key
                            to switch on: `high` (>= 700), `medium` (>= 300), `low`
                            below that, `none` when the idea is awaiting its first
                            score. Travels with `rice_score` and is OMITTED under
                            the same gate (RICE scoring off for the workspace).'
                          properties:
                            tier:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
                              - none
                              example: high
                            label:
                              type: string
                              description: Human label for the band.
                              example: High
                            color:
                              type: string
                              description: Foreground hex the web uses.
                              example: "#146c43"
                            background:
                              type: string
                              description: Background hex the web uses; `transparent`
                                for the `none` band.
                              example: "#d1f0e0"
                        voting_closes_on:
                          type: string
                          format: date
                          description: The idea's voting close date (ISO `YYYY-MM-DD`).
                            Present ONLY when the tenant has the voting-close-date
                            feature enabled (`enable_close_date`) AND this idea has
                            a date set — the key is OMITTED otherwise (not null).
                          example: '2026-08-15'
                  meta:
                    type: object
                    description: Pagination over the matching set.
                    properties:
                      total_count:
                        type: integer
                        description: Ideas matching the search.
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/review_queue":
    get:
      tags:
      - Ideas
      summary: My Review Queue
      description: |
        The native-client mirror of the web "My Review Queue"
        (Apps::Ideas::ReviewQueueController#index queue tab): the ideas routed to
        a review panel the caller belongs to, ranked by RICE priority (by votes
        when RICE scoring is turned off). Backed by
        the SAME query object semantics the web uses (::Ideas::ReviewQueueQuery),
        so the two surfaces can't drift.

        **Panel membership is the only grant** — admins get no bypass. A caller on
        no review panel receives `on_panel: false` with an empty list and zeroed
        counts (HTTP 200, NOT 403), mirroring the web tab's empty state; the client
        should hide the Review tab for such users (see
        `GET /api/v1/apps?include_navigation=true`, which only exposes the Reviews
        item to reviewers).

        **Campaign filter** (`campaign`, default `all`):
        * `all`  — every idea in the caller's accessible set.
        * `none` — only ideas not attached to any campaign.
        * `<id>` — only that campaign's ideas. Use an `id` from `campaigns`
          (the campaigns represented in the caller's accessible set). A
          non-numeric value falls back to `all`.
        With campaigns switched off in Ideas Settings, `campaigns` is empty and
        only `none` is honoured — a campaign id falls back to `all`.

        **Awaiting-score sub-filter** (`needs`): when truthy, only ideas still
        waiting for their first RICE score are returned — unscored and not yet in
        an Implemented or Declined stage. Ignored when RICE scoring is turned off
        for the workspace.

        **`needs_scoring_count`** badges the "awaiting score" chip: the number of
        ideas waiting for a first score WITHIN the active campaign filter. It is measured on the
        campaign-filtered set (not the `needs`-filtered one), so it stays stable
        while the toggle is on. Zero when scoring is off.

        Ordering is RICE `rice_score` DESC (unscored ideas sink to the bottom),
        then `created_at` DESC, then `id` DESC as a stable tiebreak. With RICE
        scoring turned off it is `up_votes_count` DESC, then `created_at` DESC,
        then `id` DESC — the web queue's own fallback — so a score stored while
        scoring was on never decides the order.
        `meta.total_count` reflects the ACTIVE filters (it is what pagination is
        over).
      security:
      - BearerAuth: []
      parameters:
      - name: campaign
        in: query
        required: false
        description: all (default) | none (ideas not in any campaign) | <campaign_id>.
          A non-numeric value falls back to all.
        schema:
          type: string
          default: all
          example: none
      - name: needs
        in: query
        required: false
        description: |-
          Only ideas awaiting their first RICE score. Ignored when scoring is disabled.
          Declared as a boolean, and it now BEHAVES as one: only `1`, `t`, `true` or `on` turns the filter on, and every other value — including the `false` / `0` a generated client emits for an unchecked chip — leaves it off, exactly as omitting the key does. (Before 2026-09-10 the value was read for mere presence, so `needs=false` switched the filter ON and a reviewer who unchecked "Awaiting score" watched their already-scored items disappear.) Same true-spelling allowlist as this API's `with_ideas` and `open_for_submissions`.
        schema:
          type: boolean
          example: true
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Review queue retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - on_panel
                - ideas
                - active_campaign
                - needs_score
                - needs_scoring_count
                - campaigns
                - meta
                properties:
                  on_panel:
                    type: boolean
                    description: Whether the caller belongs to any review panel. When
                      false, `ideas` is empty and the counts are zero.
                    example: true
                  active_campaign:
                    type: string
                    description: The campaign filter actually applied (`all` | `none`
                      | `<id>`; echoes an unknown value back as `all`).
                    example: none
                  needs_score:
                    type: boolean
                    description: Whether the awaiting-score sub-filter is active (always
                      false when scoring is off).
                    example: false
                  needs_scoring_count:
                    type: integer
                    description: Ideas awaiting their first score within the active
                      campaign filter (stable while `needs` is on; 0 when scoring
                      is off).
                    example: 8
                  ideas:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - display_id
                      - title
                      - stage
                      - creator
                      - comments_count
                      - created_at
                      - vote_count
                      - has_voted
                      - voting_closed
                      - description
                      properties:
                        id:
                          type: integer
                          example: 91
                        display_id:
                          type: string
                          description: The human reference shown on the web ("ID-<n>").
                          example: ID-91
                        title:
                          type: string
                          example: Add SSO for contractors
                        stage:
                          type: object
                          description: The idea's current lifecycle stage.
                          properties:
                            id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Planned
                            color:
                              type: string
                              description: The stage's whitelist-coerced hex color.
                              example: "#2e63b3"
                        creator:
                          type: object
                          properties:
                            id:
                              type: integer
                              nullable: true
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              description: The author's full name.
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL (a ui-avatars initial
                                tile when the user has no photo).
                              example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                        comments_count:
                          type: integer
                          description: Non-deleted comments + replies.
                          example: 4
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-07-31T06:01:58Z'
                        vote_count:
                          type: integer
                          description: Positive votes (up_votes_count counter cache).
                          example: 12
                        has_voted:
                          type: boolean
                          description: Whether the calling user has upvoted this idea.
                          example: true
                        voting_closed:
                          type: boolean
                          description: True once the idea's voting close date has
                            passed — but only on a workspace where `close_date_enabled`
                            is true. A workspace that has the voting-close-date feature
                            off never reports a closed idea and never refuses a vote,
                            and `voting_closes_on` is withheld. The date ends at midnight
                            in the WORKSPACE's time zone, so draw the padlock from
                            this flag rather than by comparing `voting_closes_on`
                            with the device's day.
                          example: false
                        can_vote:
                          type: boolean
                          description: 'Whether this request may press the upvote
                            at all: false once `voting_closed`, and for an API token
                            without write:ideas, which `POST` / `DELETE /ideas/{idea_id}/vote`
                            ask (a signed-in session always holds it). Taking a vote
                            back also needs `vote_change_allowed`.'
                          example: true
                        rice_score:
                          type: integer
                          nullable: true
                          description: Cached RICE score; null when the idea is awaiting
                            its first score. OMITTED entirely while RICE scoring is
                            off for the workspace.
                          example: 42
                        rice_score_band:
                          type: object
                          description: 'The colour band `rice_score` renders as, so
                            a client paints the score pill the same way the web does
                            without hardcoding our ranges. `tier` is the stable key
                            to switch on: `high` (>= 700), `medium` (>= 300), `low`
                            below that, `none` when the idea is awaiting its first
                            score. Travels with `rice_score` and is OMITTED under
                            the same gate (RICE scoring off for the workspace).'
                          properties:
                            tier:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
                              - none
                              example: high
                            label:
                              type: string
                              description: Human label for the band.
                              example: High
                            color:
                              type: string
                              description: Foreground hex the web uses.
                              example: "#146c43"
                            background:
                              type: string
                              description: Background hex the web uses; `transparent`
                                for the `none` band.
                              example: "#d1f0e0"
                        description:
                          type: string
                          nullable: true
                          description: The idea's plain-text description (description_text);
                            null for legacy ideas saved before a description was required.
                          example: Contractors need SSO so we can offboard them centrally.
                        voting_closes_on:
                          type: string
                          format: date
                          description: The idea's voting close date (ISO `YYYY-MM-DD`).
                            Present ONLY when the tenant has the voting-close-date
                            feature enabled (`enable_close_date`) AND this idea has
                            a date set — the key is OMITTED otherwise (not null).
                            Mirrors the web, which shows the "Voting closes …" pill
                            under both conditions.
                          example: '2026-08-15'
                        scored_by_author:
                          type: boolean
                          description: 'True while the idea''s only RICE score is
                            the one its AUTHOR set — not the panel''s review, so the
                            idea is still waiting for one (the web row says "Scored
                            by its author"). A reviewer confirms it by saving the
                            same values (`PATCH /ideas/{idea_id}/score` answers `confirmed:
                            true`). False once a colleague has scored or decided it,
                            and while RICE scoring is off.'
                          example: false
                        stage_set_by_author:
                          type: boolean
                          description: 'True while the idea''s decided stage is one
                            its AUTHOR moved it to (the web row says "Moved by its
                            author"). A reviewer confirms it by "moving" it to that
                            same stage (`PATCH /ideas/{idea_id}/stage` answers `confirmed:
                            true`). False once a colleague has acted.'
                          example: false
                  campaigns:
                    type: array
                    description: The campaigns represented in the caller's accessible
                      set — the options for the `campaign` filter.
                    items:
                      type: object
                      required:
                      - id
                      - title
                      properties:
                        id:
                          type: integer
                          example: 4
                        title:
                          type: string
                          example: Q4 Cost Savings
                  meta:
                    type: object
                    description: Pagination over the ACTIVE filters.
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the active filters.
                        example: 23
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 2
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
  "/ideas/campaigns":
    get:
      tags:
      - Ideas
      summary: Idea campaigns list + search
      description: |
        The native-client mirror of the web Campaigns index
        (`Apps::Ideas::CampaignsController#index`). A paginated, phase-filtered list
        of campaigns backed by the SAME query object and phase SQL the web uses
        (`Ideas::CampaignListQuery` + `Campaign.phase_sql`), so the two surfaces
        can't drift.

        **Requires campaigns to be enabled.** The whole endpoint is gated on the
        Ideas `campaigns` setting (on unless an admin turns it off). When it is off
        the response is `403` with error code `campaigns_disabled` — distinct from
        the app-access `403 access_denied` — so a client can hide the Campaigns tab
        rather than showing it and failing on tap.

        **`status` is the DERIVED phase**, not the stored `status` enum (which only
        has open/closed and would report a future-dated campaign as "open"):
        * `scheduled` — the start date is in the future.
        * `closed` — manually closed, or the close date has passed.
        * `open` — everything else (a campaign with no close date is open-ended).

        **Filter** (`filter`, default `all`): `all` | `open` | `scheduled` |
        `closed`. An unknown value falls back to `all`.

        **Search** (`q`, optional): a case-insensitive substring matched against the
        campaign **title OR description (brief)** — the same `Campaign.search`
        predicate the web index uses, so a term returns the same campaigns on both
        surfaces. A mid-word slice matches (`ffice` finds "Green Office"); `%` and
        `_` are matched literally rather than as wildcards. Blank/absent is a no-op.
        The applied term is echoed back as `query` (trimmed, `""` when not
        searching) so a client can render "N campaigns for “term”".

        Search composes with `filter` and with pagination, and it narrows the
        `counts` as well as the rows — see **Counts** below. A term that matches
        nothing is a normal `200` with an empty `campaigns` array and zeroed
        counts, never an error.

        **Order** is the per-tab order the web uses: Open → closing soonest first
        (open-ended last), Scheduled → opening soonest first, Closed → most recently
        ended first, All → title A–Z. Every order ends in a title + id tiebreak, so
        paging never repeats or skips a row.

        **Review panel** (`reviewers`, per row) is the panel that campaign's ideas
        route to: its own group when it names one, else the **workspace default**. It
        carries the group (`id` + `name`), the panel's FULL member `count`, and up to
        **3** `members` for an avatar stack. Three things worth reading twice:

        * `count` is the panel's real size, **not** `members.size` — the preview caps
          at 3, so a client rendering "N reviewers" off the array would say 3 for a
          40-person panel.
        * `members` is ordered by user id ascending, which makes it exactly the first
          page of `GET /ideas/campaigns/{id}/reviewers` — the card and the roster it
          opens cannot disagree. Use that endpoint for the paginated, searchable list.
        * `members` is **omitted entirely** while the `reviewer_names` setting is off
          (`group` and `count` still ship). Same withholding the campaign detail, the
          idea detail and the roster endpoint apply, so a tenant that hides reviewer
          identities hides them here too.

        A stale `reviewer_group_id`, or one belonging to another tenant, reads as
        **unset** (`group: null`, `count: 0`) rather than leaking a foreign group name.

        **Counts** are filter-blind but search-scoped. `counts.all` / `open` /
        `scheduled` / `closed` let the client badge every chip without re-fetching,
        and `all` is exactly the sum of the three phases. They describe the whole
        business when not searching, and the MATCHING campaigns when `q` is present
        (so each chip badges what tapping it actually returns). `meta.total_count`,
        by contrast, reflects the ACTIVE filter — it is what pagination is over.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: Derived-phase filter. An unknown value falls back to `all`.
        schema:
          type: string
          enum:
          - all
          - open
          - scheduled
          - closed
          default: all
      - name: q
        in: query
        required: false
        description: Search term — case-insensitive substring matched against the
          campaign title OR description (brief). Matches mid-word; `%` and `_` are
          literal, not wildcards. Blank/absent disables the search. Narrows `counts`
          as well as the rows, and composes with `filter` + pagination.
        schema:
          type: string
          example: savings
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Campaigns retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaigns
                - active_filter
                - query
                - counts
                - meta
                properties:
                  active_filter:
                    type: string
                    description: The filter actually applied (echoes an unknown value
                      back as `all`).
                    example: all
                  query:
                    type: string
                    description: The search term actually applied, trimmed. Empty
                      string when not searching. Echoed so a client can label the
                      results ("N campaigns for “term”").
                    example: savings
                  campaigns:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - description
                      - status
                      - start_date
                      - end_date
                      - icon
                      - color
                      - color_hex
                      - has_my_idea
                      - ideas_count
                      - reviewers
                      properties:
                        id:
                          type: integer
                          example: 12
                        title:
                          type: string
                          example: Q4 Cost Savings
                        description:
                          type: string
                          nullable: true
                          description: The campaign brief.
                          example: Ideas to cut operating cost this quarter.
                        status:
                          type: string
                          description: The DERIVED phase (see above) — not the stored
                            status enum.
                          enum:
                          - open
                          - scheduled
                          - closed
                          example: open
                        start_date:
                          type: string
                          format: date
                          nullable: true
                          description: ISO-8601 date; null when the campaign has no
                            start date.
                          example: '2026-07-21'
                        end_date:
                          type: string
                          format: date
                          nullable: true
                          description: ISO-8601 date; null when the campaign is open-ended.
                          example: '2026-08-20'
                        ended_on:
                          type: string
                          format: date
                          nullable: true
                          description: ISO-8601 date the campaign actually ended,
                            present only once its status is closed (null otherwise).
                            A campaign closed early keeps a future end_date, so show
                            this as its end.
                          example: '2026-08-14'
                        icon:
                          type: string
                          description: Font Awesome glyph name with no `fa-` prefix
                          coerced to the authored set (default `bullhorn`).:
                          example: rocket
                        color:
                          type: string
                          description: Palette key
                          whitelist-coerced (default `blue`).:
                          enum:
                          - blue
                          - indigo
                          - purple
                          - magenta
                          - red
                          - sunset
                          - amber
                          - forest
                          - teal
                          - slate
                          example: forest
                        color_hex:
                          type: string
                          description: The palette key's primary hex
                          so a client can render without shipping the palette.:
                          example: "#2d6a4f"
                        has_my_idea:
                          type: boolean
                          description: Whether the CALLING user has authored at least
                            one idea in this campaign.
                          example: true
                        ideas_count:
                          type: integer
                          description: Ideas submitted to this campaign.
                          example: 2
                        reviewers:
                          type: object
                          description: The review panel this campaign's ideas route
                            to — its own group when it names one, else the workspace
                            default panel (the same fallback the web card and the
                            campaign detail apply, so a card can never name a different
                            panel than the detail it opens).
                          required:
                          - group
                          - count
                          properties:
                            group:
                              type: object
                              nullable: true
                              description: "`null` (with `count: 0`) when the campaign
                                names no panel AND the workspace has no default —
                                and also when its saved `reviewer_group_id` is stale
                                or belongs to another tenant, which reads as unset
                                rather than leaking the foreign group's name."
                              required:
                              - id
                              - name
                              properties:
                                id:
                                  type: integer
                                  example: 44387
                                name:
                                  type: string
                                  example: Product Council
                            count:
                              type: integer
                              description: The panel's FULL member count — **not**
                                `members.size`. The preview is capped at 3, so rendering
                                "N reviewers" off the array length would report 3
                                for a 40-person panel.
                              example: 12
                            members:
                              type: array
                              maxItems: 3
                              description: Up to **3** panel members for an avatar
                                stack, ordered by user id ascending — the same leading
                                slice `GET /ideas/campaigns/{id}/reviewers` returns
                                on page 1, so a card's avatars and the roster screen
                                it opens agree. **OMITTED entirely** while the `reviewer_names`
                                setting is off (`group` and `count` still ship, so
                                a card can say "Reviewed by <panel> (N)" without naming
                                anyone). Use the roster endpoint for the paginated,
                                searchable full list.
                              items:
                                type: object
                                required:
                                - id
                                - name
                                - photo
                                properties:
                                  id:
                                    type: integer
                                    example: 63
                                  name:
                                    type: string
                                    nullable: true
                                    example: Grace Dalton
                                  photo:
                                    type: string
                                    description: Absolute avatar URL; never null (falls
                                      back to a generated initials image).
                                    example: https://…/avatar.jpg
                  counts:
                    type: object
                    description: 'Filter-blind but search-scoped badge counts: the
                      whole business when not searching, the MATCHING campaigns when
                      `q` is present. `all` is the sum of the three phases. All zero
                      when a search matches nothing.'
                    required:
                    - all
                    - open
                    - scheduled
                    - closed
                    properties:
                      all:
                        type: integer
                        example: 8
                      open:
                        type: integer
                        example: 3
                      scheduled:
                        type: integer
                        example: 2
                      closed:
                        type: integer
                        example: 3
                  meta:
                    type: object
                    description: Pagination over the ACTIVE filter (and search, when
                      `q` is present).
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the active filter + search.
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            or campaigns are turned off for the workspace (error code `campaigns_disabled`).
    post:
      tags:
      - Ideas
      summary: Launch an idea campaign
      description: |
        Creates a campaign — the native mirror of the web **+ New Campaign** form
        (`Apps::Ideas::CampaignsController#create`), sharing
        `Ideas::CampaignCreationService` so the two surfaces produce identical rows.

        Only **`title`** and **`brief`** are required. Everything else has the same
        default the web form pre-fills, so the minimal request creates exactly what
        the web would.

        **The campaign is created open**, but `status` on every campaign response is
        the DERIVED phase (`Campaign#phase`), not the stored enum — so a campaign
        posted with a future `start_date` comes back `scheduled`, and one with a past
        `close_date` comes back `closed`. That is the same derivation the list, its
        filters and its counts use.

        **Values a browser form could never get wrong are validated here**, because a
        native client has no radio buttons or date pickers to constrain it:
        * `icon` / `color` must be from the authored sets (see the enums below). An
          unknown value is a `422`, not a silent default — storing a glyph the reader
          would coerce away shows the creator an icon they never picked. Omit either
          and you get the web form's default (`bullhorn` / `blue`).
        * dates are parsed strictly as ISO-8601 `YYYY-MM-DD` (what the web's
          `date_field` posts). `01/09/2026` is a `422`, deliberately: reading it
          leniently would book the campaign in a month the client never stated.
        * `reviewer_group_id` must name a group in **this** business — an id from
          another tenant is a `422`, never a campaign routed to a foreign panel.

        **`reviewer_group_id` has three distinct behaviours**, matching the web form:
        * **omitted** — the workspace's configured campaign panel
          (`campaign_reviewer_group_id`, reported by `GET /ideas/config` as
          `campaign_reviewers` / `campaign_options.default_reviewer_group_id`) is
          applied. This is what the web form pre-selects, so a client that renders no
          picker still lands where the web would.
        * **sent empty** (`""` or `null`) — no panel is stored, and the campaign
          resolves to the workspace default panel at read time. This is the web's
          *"— Workspace default —"* option.
        * **an id** — that panel reviews this campaign's ideas.

        **Authorization** is the workspace's *"Who can create campaigns"* audience
        (`campaign_creators`) — everyone, or only one configured group, with no admin
        bypass. Reading campaigns is not enough (`403 forbidden`). Campaigns being
        turned off for the workspace answers the broader `403 campaigns_disabled`
        first, so a client hides the whole tab rather than the one affordance. Check
        `can_create_campaign` on `GET /ideas/config` to decide whether to show the
        "+" at all.

        **`GET /ideas/config` → `campaign_options`** carries the composer's option
        data (the icon set, the palette with hex values, and this workspace's
        selectable reviewer groups), so a native New Campaign screen can only offer
        values this endpoint accepts.

        Responds **201** with the SAME canonical campaign object
        `GET /api/v1/ideas/campaigns/{id}` returns — one query object and one
        serializer back both — so a client can push straight to the detail screen
        without a second call. `ideas` is empty and `ideas_count` is `0` on a fresh
        campaign.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - brief
              properties:
                title:
                  type: string
                  maxLength: 120
                  description: Required. Surrounding whitespace is stripped.
                  example: Mobile Experience 2026
                brief:
                  type: string
                  description: Required — what the campaign is about and what kind
                    of ideas it wants. This is the text every campaign response returns
                    as `description`; that name is accepted here too (as an alias),
                    so a client can post back the object it just read without renaming
                    a key. `brief` wins when both are sent, and an explicitly blank
                    `brief` is a `422` rather than silently falling through to `description`.
                  example: Ideas to make the frontline mobile app faster and simpler.
                description:
                  type: string
                  description: Alias for `brief` (see above). Ignored when `brief`
                    is present.
                icon:
                  type: string
                  description: Optional Font Awesome glyph name with no `fa-` prefix.
                    Defaults to `bullhorn` (the web form's default). An unlisted value
                    is a `422` `invalid_icon`.
                  enum:
                  - bullhorn
                  - mobile-screen-button
                  - user-plus
                  - wand-magic-sparkles
                  - shield-halved
                  - rocket
                  - lightbulb
                  - bolt
                  - heart
                  - leaf
                  - chart-line
                  - gear
                  - calendar-days
                  - clipboard-check
                  - comments
                  - headset
                  - store
                  - truck
                  - users
                  - star
                  - flag
                  - graduation-cap
                  - globe
                  - briefcase
                  default: bullhorn
                  example: rocket
                color:
                  type: string
                  description: Optional palette key for the campaign's card and chips.
                    Defaults to `blue`. An unlisted value is a `422` `invalid_color`.
                    The response echoes both the key (`color`) and its primary hex
                    (`color_hex`).
                  enum:
                  - blue
                  - indigo
                  - purple
                  - magenta
                  - red
                  - sunset
                  - amber
                  - forest
                  - teal
                  - slate
                  default: blue
                  example: forest
                start_date:
                  type: string
                  format: date
                  nullable: true
                  description: Optional ISO-8601 `YYYY-MM-DD`. Submissions open on
                    this date — a future value makes the campaign `scheduled`. Also
                    accepted as `start_on` (the web's own field name). Anything that
                    isn't a well-formed ISO day is a `422` `invalid_date`.
                  example: '2026-09-01'
                close_date:
                  type: string
                  format: date
                  nullable: true
                  description: Optional ISO-8601 `YYYY-MM-DD`, and must be on or after
                    `start_date` (else `422 invalid`). Submissions close automatically
                    on this date. Also accepted as `end_date` (the key the response
                    returns it under) and `close_on` (the web's field name). Leave
                    both dates blank for an open-ended campaign.
                  example: '2026-09-30'
                reviewer_group_id:
                  type: integer
                  nullable: true
                  description: Optional. The panel that manages the review lifecycle
                    for this campaign's ideas. Omit the key to inherit the workspace's
                    configured campaign panel; send it empty/null to store no panel
                    (resolving to the workspace default at read time); send an id
                    from `GET /ideas/config` → `campaign_options.reviewer_groups`.
                    An id outside this business is a `422` `invalid_reviewer_group`.
                  example: 7
      responses:
        '201':
          description: Campaign created
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                properties:
                  campaign:
                    type: object
                    description: The created campaign, in the SAME shape `GET /api/v1/ideas/campaigns/{id}`
                      returns — see that endpoint for the full field list. `ideas`
                      is `[]`, `ideas_count` is `0` and `has_my_idea` is `false` on
                      a fresh campaign.
                    properties:
                      id:
                        type: integer
                        example: 31
                      title:
                        type: string
                        example: Mobile Experience 2026
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                      status:
                        type: string
                        description: The DERIVED phase, not the stored enum.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-09-01'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-09-30'
                      ended_on:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601 date the campaign actually ended, present
                          only once its status is closed (null otherwise). A campaign
                          closed early keeps a future end_date, so show this as its
                          end.
                        example: '2026-08-14'
                      icon:
                        type: string
                        example: rocket
                      color:
                        type: string
                        example: forest
                      color_hex:
                        type: string
                        example: "#2d6a4f"
                      ideas_count:
                        type: integer
                        example: 0
                      has_my_idea:
                        type: boolean
                        example: false
                      creator:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 4
                          name:
                            type: string
                            example: Ada Adminson
                          photo:
                            type: string
                            nullable: true
                      reviewers:
                        type: object
                        description: 'The panel this campaign routes to (its own group,
                          else the workspace default): its `group.name`, the panel''s
                          FULL `count`, and an avatar preview of at most 3 `members`.
                          `members` is withheld while the "Show reviewer names" setting
                          is off.'
                        properties:
                          group:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 7
                              name:
                                type: string
                                example: Product Council
                          count:
                            type: integer
                            description: The panel's full size
                            not the preview length.:
                            example: 8
                          members:
                            type: array
                            maxItems: 3
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                      can_edit:
                        type: boolean
                        description: Whether the CALLER may manage the campaign they
                          just created — always `true` here (they are its creator),
                          and the same flag `GET /ideas/campaigns/{id}` reports.
                        example: true
                      can_delete:
                        type: boolean
                        description: Always equal to `can_edit` (one manage right).
                        example: true
                      ideas:
                        type: array
                        description: Always empty on create.
                        items:
                          type: object
                      ideas_meta:
                        type: object
                        properties:
                          total_count:
                            type: integer
                            example: 0
                          current_page:
                            type: integer
                            example: 1
                          per_page:
                            type: integer
                            example: 50
                          total_pages:
                            type: integer
                            example: 0
                          sort:
                            type: string
                            example: top
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Campaigns are turned off for the workspace (error code `campaigns_disabled`,
            checked first), the caller is outside the `campaign_creators` audience
            (`forbidden`), or the Ideas app is not accessible to them (`access_denied`).
        '422':
          description: 'The campaign was not created. `error.code` says why, and `error.message`
            is the sentence to show: `invalid` — a missing/blank title or brief, a
            title over 120 characters, or a close date before the start date; `invalid_icon`
            / `invalid_color` — a value outside the authored set (the message lists
            the valid ones); `invalid_date` — a date that isn''t a well-formed ISO-8601
            `YYYY-MM-DD`; `invalid_reviewer_group` — a group id that doesn''t belong
            to this workspace; `create_failed` — the write itself failed; retry. Also
            `content_blocked` when the workspace''s content-moderation policy refuses
            the submitted text; the message is the policy''s own and is safe to show
            the author verbatim.'
  "/ideas/campaigns/ai_brief":
    post:
      tags:
      - Ideas
      summary: Draft a campaign brief with AI
      description: |
        The native mirror of the web's **"Draft with AI"** sparkles button beside the
        Brief field on the New/Edit Campaign form
        (`Apps::Ideas::CampaignsController#ai_brief`). Both surfaces call
        `Ideas::AiDraftService#draft_campaign_brief`.

        **Writes nothing and launches no campaign.** It returns text for the Brief
        field; launching is still `POST /ideas/campaigns`. Safe to call repeatedly —
        each call re-drafts.

        **`title` is optional and may be blank.** The web button carries
        `formnovalidate` so an author can ask for a brief before committing to a
        title, and the service drafts about "this topic" when it gets nothing. A blank
        title is a `200` with usable text, never a `422`.

        See `POST /ideas/ai_draft` for why `fallback` and `notice` are part of the
        contract rather than decoration — the shape is identical.

        **Access** — the workspace `ai_assist` setting must be on (`403`
        `ai_assist_disabled`), campaigns must be enabled for the workspace (`403`
        `campaigns_disabled`), and the caller must be able to reach a campaign FORM on
        the web: the `campaign_creators` audience (the New screen) **or** an Ideas
        admin — the Edit screen's `require_campaign_manager` admits an admin who sits
        outside that audience, so gating on the audience alone would 403 them out of
        the button their own Edit screen renders. `GET /ideas/config` reports
        `ai_assist`, `campaigns_enabled` and `can_manage_campaigns` up front.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  description: The campaign title typed so far. Optional; may be blank.
                  example: Q4 Cost Savings
      responses:
        '200':
          description: A draft. Nothing was written.
          content:
            application/json:
              schema:
                type: object
                properties:
                  draft:
                    type: object
                    properties:
                      text:
                        type: string
                        description: The drafted brief, ready to place in the Brief
                          field.
                      fallback:
                        type: boolean
                        description: True when the LLM was unavailable and this is
                          the LOCAL outline. Badge it differently — do not present
                          it as "Drafted with AI".
                        example: false
                      notice:
                        type: string
                        description: The banner copy for whichever case applied —
                          the web's exact string.
                        example: Drafted with AI — edit anything before launching.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            campaigns are turned off for the workspace (error code `campaigns_disabled`),
            AI writing assistance is turned off (error code `ai_assist_disabled`),
            or the caller may not author a campaign (error code `forbidden`).
  "/ideas/campaigns/lite":
    get:
      tags:
      - Ideas
      summary: Idea campaigns picker (lite)
      description: |
        The stripped-down twin of `GET /ideas/campaigns`: just `{ id, name }` per
        campaign. Built for a campaign filter/dropdown, where the full card payload is
        wasted bytes.

        **`with_ideas` chooses the set** (default `true`):

        * `with_ideas=true` (or the parameter omitted) — only campaigns that hold
          **at least one idea**. Use this for a FILTER over existing ideas (the feed
          filter, the review queue), where an empty campaign is a dead option that
          would return nothing. A campaign appears the moment its first idea is
          submitted and drops out when its last idea is deleted or detached.
        * `with_ideas=false` — **every** campaign in the workspace, empty ones
          included. Use this for a DESTINATION picker (submitting an idea to a
          campaign, moving one), because a campaign is empty precisely until someone
          submits the first idea to it — filtering empties out would hide the campaign
          an admin just launched.

        `true` is the default because it is what this endpoint returned before the
        parameter existed, so a client that sends nothing sees no change. Only the
        standard false spellings (`false`, `0`, `f`, `off`) turn it off; any other
        value — including a blank `?with_ideas=` — reads as `true`, so a malformed
        request never silently widens the list. The applied value is echoed back as
        `with_ideas`.

        **`open_for_submissions` restricts to submittable campaigns** (default
        `false`):

        * `open_for_submissions=false` (or the parameter omitted) — **every phase**:
          open, scheduled and closed alike. A closed campaign's ideas still exist and
          are still a legitimate thing to filter to, so a FILTER must be offered it.
        * `open_for_submissions=true` — only campaigns an idea can actually be
          submitted to. This is the native mirror of the **web "Post an Idea" campaign
          dropdown**: both surfaces read the same
          `::Ideas::Campaign.open_for_submissions` scope, so they cannot offer a
          different option set for the same workspace. It excludes a manually closed
          campaign, a **scheduled** one (its start date is still in the future) and one
          whose **close date has passed** — note the last two are still `status: open`
          on the campaign list, and all three are rejected on submission with
          `campaign is not open for submissions` (`422`), so offering them in a
          submission picker offers a guaranteed failure. A campaign starting today or
          closing today is still open.

        Default `false` for backwards compatibility. Symmetrically to `with_ideas`,
        only the standard true spellings (`true`, `1`, `t`, `on`) turn it on; any other
        value — including a blank `?open_for_submissions=` — reads as `false`, so a
        malformed request never silently *narrows* the list. The applied value is
        echoed back as `open_for_submissions`.

        > ⚠️ **`open_for_submissions=true` flips the `with_ideas` default to `false`.**
        > A campaign holds no ideas precisely until someone submits the first one to
        > it, so intersecting both defaults would hide the campaign an admin just
        > launched — exactly the failure the `with_ideas` note above warns about. Send
        > `with_ideas` explicitly to override this in either direction; every
        > combination stays reachable, and both applied values come back in the
        > response.

        * `name` is the campaign **title** (the same string `title` carries on the
          full list).
        * **Unpaginated by design** — a picker needs the whole option set in one
          call, so there is no `meta` envelope and no `page` / `per_page` parameter.
          Note that `with_ideas=false` therefore returns every campaign the workspace
          has ever created, which in a long-lived tenant is a larger payload than the
          filtered list.
        * Ordered by name, A–Z (case-insensitive), with an id tiebreak so the list is
          stable across calls. Identical in every mode.

        **Requires campaigns to be enabled**, exactly like the full list: when the
        Ideas `campaigns` setting is off the response is `403` with error code
        `campaigns_disabled`, distinct from the app-access `403 access_denied`.
      parameters:
      - name: with_ideas
        in: query
        required: false
        description: "`true` (default) returns only campaigns holding at least one
          idea; `false` returns every campaign. Recognised false values are `false`,
          `0`, `f` and `off` — anything else, including a blank value, is treated
          as `true`. The default becomes `false` when `open_for_submissions=true`;
          send this parameter explicitly to override that."
        schema:
          type: boolean
          default: true
      - name: open_for_submissions
        in: query
        required: false
        description: '`true` returns only campaigns an idea can actually be submitted
          to — the same set the web "Post an Idea" dropdown offers, excluding manually
          closed, scheduled (future start date) and past-close-date campaigns. `false`
          (default) returns every phase. Recognised true values are `true`, `1`, `t`
          and `on` — anything else, including a blank value, is treated as `false`.
          Setting it to `true` also flips the `with_ideas` default to `false`.'
        schema:
          type: boolean
          default: false
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Picker list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaigns
                - with_ideas
                - open_for_submissions
                properties:
                  campaigns:
                    type: array
                    description: The matching campaigns, name-ascending — narrowed
                      to those holding at least one idea when `with_ideas` is true,
                      and to those open for submissions when `open_for_submissions`
                      is true. Empty when the workspace has nothing to offer.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      properties:
                        id:
                          type: integer
                          example: 12
                        name:
                          type: string
                          description: The campaign title.
                          example: Q4 Cost Savings
                  with_ideas:
                    type: boolean
                    description: The emptiness filter actually applied — echoed so
                      a client can confirm which set it received. True when the parameter
                      was omitted, blank or unrecognised, EXCEPT under `open_for_submissions=true`,
                      where the default is false.
                    example: true
                  open_for_submissions:
                    type: boolean
                    description: The phase filter actually applied — echoed for the
                      same reason. False when the parameter was omitted, blank or
                      unrecognised.
                    example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            or campaigns are turned off for the workspace (error code `campaigns_disabled`).
  "/ideas/campaigns/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: One campaign's detail
      description: |
        A single campaign with its creator, its review panel, and a PAGINATED page of
        its ideas. The native mirror of the web campaign detail
        (`Apps::Ideas::CampaignsController#show`), backed by the shared
        `::Ideas::CampaignDetailQuery` — so the idea ordering, the reviewer-panel
        fallback and the totals cannot drift between surfaces.

        **Requires campaigns to be enabled**, like the rest of this section: when the
        Ideas `campaigns` setting is off the response is `403` with error code
        `campaigns_disabled` (distinct from the app-access `403 access_denied`).

        The campaign's own fields are shaped by the SAME serializer the campaigns
        LIST uses, so a card and its detail always agree: `status` is the DERIVED
        phase (`open` / `scheduled` / `closed` — never the stored open/closed enum),
        `icon` and `color` are whitelist-coerced, and the dates are ISO-8601.

        **The ideas list is paginated** (`page` / `per_page`, default 50 — the web's
        page size — clamped to 50). A campaign's thread is unbounded, so an
        unpaginated nested list would be a latent timeout. Crucially, `ideas_count`
        and `ideas_meta.total_count` are computed on the UNPAGINATED scope: they
        report the campaign's TRUE size, not the size of the page you asked for.

        **Sort** (`sort`, default `top`) mirrors the web's options:
        * `top` — most upvoted first (the default)
        * `new` — newest first
        * `discussed` — most comments (replies included) first
        * `score` — highest RICE first, unscored last. Degrades to `top` when RICE
          scoring is off for the workspace, exactly as the web does. An unknown value
          degrades to `top` too. The applied value is echoed as `ideas_meta.sort`.

        Every sort ends in an `id` DESC tiebreak, so paging never repeats or skips an
        idea — all four sort keys are non-unique (votes, a comment count, a nullable
        RICE score), and ties are common in a young campaign.

        **`reviewers`** resolves the panel the campaign routes to: its own group when
        it names one, else the workspace default — the same fallback the web detail
        and the list card apply. It carries three things:
        * `group.name` — the panel's name, for "Reviewed by <panel>". `group` is
          `null` (with `count: 0`) only when the campaign names no panel AND the
          workspace has no default.
        * `count` — the panel's **full** member count, whatever the preview length.
          Never read this off `members.size`; that would report 3 for a 40-person
          panel.
        * `members` — an avatar preview of at most **3** people, id-ordered. The same
          three-member preview the idea detail ships (`reviewers` /
          `reviewers_count`), so both screens render one avatar stack with a "+N"
          overflow computed from `count`. For the complete, paginated, searchable
          roster of THIS campaign's panel use
          `GET /ideas/campaigns/{id}/reviewers` — the campaign twin of
          `GET /ideas/{idea_id}/reviewers`, and the one to call here: a campaign
          screen has no `idea_id` in scope.

        `members` is **omitted entirely** while the `reviewer_names` setting is off,
        with `group` and `count` still present so a client can render
        "Reviewed by <panel> (N)" without naming anyone.

        Each idea row carries `rice_score` — and `rice_score_band`, the colour the
        web paints it — only while RICE scoring is on, matching the feed, search and
        idea-detail endpoints.
      parameters:
      - name: sort
        in: query
        required: false
        description: Ideas ordering. Unknown values (and `score` while scoring is
          off) fall back to `top`.
        schema:
          type: string
          enum:
          - top
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        description: 1-based page over the IDEAS list. An out-of-range page returns
          an empty list.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Ideas per page (default 50, clamped to 1..50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 50
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Campaign detail retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                properties:
                  campaign:
                    type: object
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    - creator
                    - reviewers
                    - can_edit
                    - can_delete
                    - ideas
                    - ideas_meta
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase — not the stored status enum.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; null when the campaign has no start
                          date.
                        example: '2026-07-21'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; null when the campaign is open-ended.
                        example: '2026-08-20'
                      ended_on:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601 date the campaign actually ended, present
                          only once its status is closed (null otherwise). A campaign
                          closed early keeps a future end_date, so show this as its
                          end.
                        example: '2026-08-14'
                      icon:
                        type: string
                        description: Font Awesome glyph name with no `fa-` prefix
                        coerced to the authored set (default `bullhorn`).:
                        example: rocket
                      color:
                        type: string
                        description: Palette key
                        whitelist-coerced (default `blue`).:
                        example: forest
                      color_hex:
                        type: string
                        description: The palette key's primary hex
                        so a client can render without shipping the palette.:
                        example: "#2d6a4f"
                      has_my_idea:
                        type: boolean
                        description: Whether the CALLING user has authored at least
                          one idea in this campaign.
                        example: true
                      ideas_count:
                        type: integer
                        description: The campaign's TRUE idea count, from the unpaginated
                          scope — unaffected by `per_page`.
                        example: 14
                      creator:
                        type: object
                        description: The person who launched the campaign.
                        required:
                        - id
                        - name
                        - photo
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 1
                          name:
                            type: string
                            nullable: true
                            example: Anup K
                          photo:
                            type: string
                            description: Absolute avatar URL; never null (falls back
                              to a generated initials image).
                            example: https://…/avatar.jpg
                      reviewers:
                        type: object
                        required:
                        - group
                        - count
                        properties:
                          group:
                            type: object
                            nullable: true
                            description: The review panel this campaign routes to
                              (its own group, else the workspace default). Null only
                              when neither is configured.
                            required:
                            - id
                            - name
                            properties:
                              id:
                                type: integer
                                example: 44387
                              name:
                                type: string
                                example: Product Council
                          count:
                            type: integer
                            description: The panel's FULL member count (0 when there
                              is no panel) — independent of how many `members` are
                              previewed, so use it for "N reviewers" and for the avatar
                              stack's "+N" overflow.
                            example: 8
                          members:
                            type: array
                            maxItems: 3
                            description: An avatar preview of at most 3 panel members,
                              id-ordered — the same preview length the idea detail
                              ships. OMITTED entirely while the `reviewer_names` setting
                              is off. Use `GET /ideas/campaigns/{id}/reviewers` for
                              the paginated, searchable roster of this campaign's
                              panel.
                            items:
                              type: object
                              required:
                              - id
                              - name
                              - photo
                              properties:
                                id:
                                  type: integer
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                      can_edit:
                        type: boolean
                        description: Whether the CALLING user may manage this campaign
                          — its creator, or an Ideas admin (business admin-or-above,
                          or the Ideas app-admin role). Managing a campaign is ONE
                          right, as it is on the web, so this same predicate gates
                          `PATCH /ideas/campaigns/{id}` and `PATCH /ideas/campaigns/{id}/reopen`.
                          Render the Edit and Reopen affordances on it and they can
                          never 403 on tap.
                        example: true
                      can_delete:
                        type: boolean
                        description: Whether the CALLING user may delete this campaign
                          — the exact predicate `DELETE /ideas/campaigns/{id}` applies.
                          Always equal to `can_edit` (one manage right); shipped as
                          its own key because Delete is a separately-rendered affordance.
                        example: true
                      ideas:
                        type: array
                        description: One page of the campaign's ideas, in the requested
                          sort.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          - description
                          - stage
                          - creator
                          - comments_count
                          - created_at
                          - vote_count
                          - has_voted
                          - voting_closed
                          properties:
                            id:
                              type: integer
                              example: 87
                            title:
                              type: string
                              example: Dark mode for the mobile app
                            description:
                              type: string
                              nullable: true
                              description: The idea's plain-text body.
                              example: Ship a dark theme for the native app.
                            stage:
                              type: object
                              required:
                              - id
                              - name
                              - color
                              properties:
                                id:
                                  type: integer
                                  example: 2
                                name:
                                  type: string
                                  example: Reviewing
                                color:
                                  type: string
                                  example: "#6c757d"
                            creator:
                              type: object
                              required:
                              - id
                              - name
                              - photo
                              properties:
                                id:
                                  type: integer
                                  nullable: true
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                            comments_count:
                              type: integer
                              description: Comments + replies (non-deleted).
                              example: 3
                            created_at:
                              type: string
                              format: date-time
                              example: '2026-07-31T09:26:59Z'
                            vote_count:
                              type: integer
                              description: Positive-vote total.
                              example: 12
                            has_voted:
                              type: boolean
                              description: Whether the CALLING user upvoted this idea.
                              example: true
                            voting_closed:
                              type: boolean
                              description: True once the idea's voting close date
                                has passed — but only on a workspace where `close_date_enabled`
                                is true. A workspace that has the voting-close-date
                                feature off never reports a closed idea and never
                                refuses a vote, and `voting_closes_on` is withheld.
                                The date ends at midnight in the WORKSPACE's time
                                zone, so draw the padlock from this flag rather than
                                by comparing `voting_closes_on` with the device's
                                day.
                              example: false
                            can_vote:
                              type: boolean
                              description: 'Whether this request may press the upvote
                                at all: false once `voting_closed`, and for an API
                                token without write:ideas, which `POST` / `DELETE
                                /ideas/{idea_id}/vote` ask (a signed-in session always
                                holds it). Taking a vote back also needs `vote_change_allowed`.'
                              example: true
                            rice_score:
                              type: integer
                              nullable: true
                              description: Cached RICE score; null when not scored.
                                OMITTED entirely while RICE scoring is off for the
                                workspace.
                              example: 42
                            rice_score_band:
                              type: object
                              description: 'The colour band `rice_score` renders as,
                                so a client paints the score pill the same way the
                                web does without hardcoding our ranges. `tier` is
                                the stable key to switch on: `high` (>= 700), `medium`
                                (>= 300), `low` below that, `none` when the idea is
                                awaiting its first score. Travels with `rice_score`
                                and is OMITTED under the same gate (RICE scoring off
                                for the workspace).'
                              properties:
                                tier:
                                  type: string
                                  enum:
                                  - high
                                  - medium
                                  - low
                                  - none
                                  example: high
                                label:
                                  type: string
                                  description: Human label for the band.
                                  example: High
                                color:
                                  type: string
                                  description: Foreground hex the web uses.
                                  example: "#146c43"
                                background:
                                  type: string
                                  description: Background hex the web uses; `transparent`
                                    for the `none` band.
                                  example: "#d1f0e0"
                            voting_closes_on:
                              type: string
                              format: date
                              description: Present only when the voting-close-date
                                feature is on AND this idea has a date set.
                              example: '2026-08-15'
                      ideas_meta:
                        type: object
                        description: Pagination over the ideas list, plus the sort
                          actually applied.
                        required:
                        - total_count
                        - current_page
                        - per_page
                        - total_pages
                        - sort
                        properties:
                          total_count:
                            type: integer
                            description: The campaign's true idea count (unpaginated).
                            example: 14
                          current_page:
                            type: integer
                            example: 1
                          per_page:
                            type: integer
                            example: 50
                          total_pages:
                            type: integer
                            description: 0 when the campaign has no ideas.
                            example: 1
                          sort:
                            type: string
                            enum:
                            - top
                            - new
                            - discussed
                            - score
                            example: top
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            or campaigns are turned off for the workspace (error code `campaigns_disabled`).
        '404':
          description: No campaign with that id in the caller's business (error code
            `not_found`).
    patch:
      tags:
      - Ideas
      summary: Edit a campaign
      description: |
        Edits a campaign — the native mirror of the web
        `Apps::Ideas::CampaignsController#update` (**Manage ▸ Edit** on the campaign
        detail, which posts exactly this field set). Values are normalised and validated
        by the SAME shared code the create endpoint uses
        (`::Ideas::CampaignAttributes`), so a payload `POST /ideas/campaigns` accepts is
        accepted here too — and rejected the same way.

        **CREATOR OR IDEAS ADMIN.** The campaign's creator may edit it, and so may an
        Ideas admin (a business admin-or-above, or a user holding the Ideas app-admin
        role) — the web's `require_campaign_manager` rule verbatim. The admin half is
        deliberate: creator-only stranded campaigns whose creator had been deactivated
        or had left, with nobody able to fix the drive. Sitting on the campaign's
        **review panel grants nothing here** — reviewing ideas and managing the drive
        that collects them are different rights. Anyone else gets `403 forbidden`. The
        same predicate backs **`can_edit`** on the campaign detail, so a client can
        render the Edit affordance exactly when this endpoint would accept it.

        **Requires campaigns to be enabled**, like the rest of this section: with the
        Ideas `campaigns` setting off the response is `403 campaigns_disabled`.

        ### PATCH semantics — this is the part to read

        Every field is **optional**, and a key you do **not** send is left
        **unchanged**. The web always posts its whole form, so sending everything
        behaves identically — but an "edit title" screen must not be able to blank the
        brief or silently re-route the review panel just by omitting them. For the
        clearable fields, sending the key **empty** (`""` or `null`) is a distinct,
        meaningful instruction:

        | field | omitted | sent EMPTY | sent with a value |
        |---|---|---|---|
        | `title` | unchanged | `422` — can't be blank | replaces it (whitespace stripped, max 120) |
        | `brief` | unchanged | `422` — can't be blank | replaces it (stripped) |
        | `icon` | unchanged | resets to `bullhorn` | must be an authored glyph, else `422 invalid_icon` |
        | `color` | unchanged | resets to `blue` | must be a palette key, else `422 invalid_color` |
        | `start_date` | unchanged | **cleared** | strict ISO-8601 `YYYY-MM-DD`, else `422 invalid_date` |
        | `close_date` | unchanged | **cleared** → open-ended | strict ISO-8601, on or after the start date |
        | `reviewer_group_id` | unchanged | **cleared** → the workspace default panel | a group in THIS business, else `422 invalid_reviewer_group` |

        An empty **date** clears it because that is exactly what the web does — its
        emptied `date_field` posts `""`, which is how a manager makes a campaign
        open-ended again. An empty **brief** is refused even when the stored brief is
        already blank (a campaign predating the field), because the model only validates
        a brief it sees change and the card body would silently render empty.

        `status` is deliberately **not writable** here: closing and reopening are
        separate actions on both surfaces (`PATCH /ideas/campaigns/{id}/reopen`), so a
        PATCH fixing a typo can never reopen a finished drive. Sending `status` is
        ignored, not an error.

        **A close date before today closes the campaign.** On a campaign that is not
        already closed (open or scheduled), a `close_date` (or `end_date` /
        `close_on`) earlier than today ends its submissions — the same act as
        `PATCH /ideas/campaigns/{id}/close` — so it asks the `destructive:ideas`
        scope **on top of** `write:ideas` (owner decision 2026-10-07, #7186). A token
        without it gets `403 insufficient_permissions` ("Required scope:
        destructive:ideas") and nothing is written. Today's date, a future date, an
        empty one, and any date on a campaign already closed stay on `write:ideas`.

        **All-or-nothing:** the first unusable value is returned and **nothing** is
        written, so a rejected edit never lands half of itself.

        Responds with the SAME canonical campaign object
        `GET /ideas/campaigns/{id}` returns (one query object, one serializer — a client
        can drop it straight into the screen it saved from), plus `changed` and
        `changed_fields`. A payload matching what is already stored is a **200 no-op**
        with `changed: false` rather than an error, so a retried or double-tapped save is
        safe. `sort` / `page` / `per_page` apply to the nested `ideas` list exactly as
        they do on the detail GET.
      parameters:
      - name: sort
        in: query
        required: false
        description: Ordering of the nested ideas list, as on the detail GET.
        schema:
          type: string
          enum:
          - top
          - new
          - discussed
          - score
          default: top
      - name: page
        in: query
        required: false
        description: 1-based page over the nested IDEAS list.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Ideas per page in the response (default 50, clamped to 1..50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 50
      security:
      - BearerAuth: []
      requestBody:
        required: true
        description: Send ONLY the fields you are changing. An empty string clears
          a date or the reviewer group, and resets the icon/colour to the form default.
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                  maxLength: 120
                  description: Whitespace is stripped. Blank is a 422.
                  example: Q4 Cost Savings
                brief:
                  type: string
                  description: The campaign's description — the text its cards and
                    detail header render. Blank is a 422.
                  example: Ideas to cut operating cost this quarter.
                description:
                  type: string
                  description: Alias for `brief` — the key every campaign GET returns
                    it under, so a client can PATCH back the object it just read.
                    `brief` wins when both are sent.
                icon:
                  type: string
                  description: Font Awesome glyph name with NO `fa-` prefix, from
                    the authored set (`GET /ideas/config` → `campaign_options.icons`).
                    Empty resets to `bullhorn`.
                  example: lightbulb
                color:
                  type: string
                  description: Palette key (`GET /ideas/config` → `campaign_options.colors`).
                    Empty resets to `blue`.
                  example: teal
                start_date:
                  type: string
                  format: date
                  nullable: true
                  description: ISO-8601 `YYYY-MM-DD`. Send empty to clear it. Alias
                    `start_on`.
                  example: '2026-09-01'
                close_date:
                  type: string
                  format: date
                  nullable: true
                  description: ISO-8601 `YYYY-MM-DD`, on or after `start_date` (including
                    a start date already stored). Send empty to clear it — the campaign
                    becomes open-ended. Aliases `end_date`, `close_on`.
                  example: '2026-09-30'
                reviewer_group_id:
                  type: integer
                  nullable: true
                  description: The panel that reviews this campaign's ideas — a group
                    in THIS business (`GET /ideas/config` → `campaign_options.reviewer_groups`).
                    Send empty to store no panel, which resolves to the workspace
                    default at read time. OMIT it and the campaign's current panel
                    is untouched.
                  example: 44387
      responses:
        '200':
          description: Campaign updated (or a no-op when the payload matched what
            was stored — check `changed`).
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                - changed
                - changed_fields
                properties:
                  campaign:
                    type: object
                    description: The updated campaign — the SAME object (same serializer,
                      same keys, same nested `ideas` page) that `GET /ideas/campaigns/{id}`
                      returns; see that operation for the full field-by-field shape.
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    - creator
                    - reviewers
                    - can_edit
                    - can_delete
                    - ideas
                    - ideas_meta
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase, not the stored enum — a campaign
                          whose start date you just moved into the future reads `scheduled`.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: Null after you clear it.
                        example: '2026-09-01'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: Null when the campaign is open-ended.
                        example: '2026-09-30'
                      ended_on:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601 date the campaign actually ended, present
                          only once its status is closed (null otherwise). A campaign
                          closed early keeps a future end_date, so show this as its
                          end.
                        example: '2026-08-14'
                      icon:
                        type: string
                        example: lightbulb
                      color:
                        type: string
                        example: teal
                      color_hex:
                        type: string
                        description: The palette key's primary hex.
                        example: "#0a97b0"
                      has_my_idea:
                        type: boolean
                        example: true
                      ideas_count:
                        type: integer
                        description: The campaign's TRUE idea count (unpaginated).
                        example: 14
                      creator:
                        type: object
                        description: "{ id, name, photo } — the person who launched
                          it."
                      reviewers:
                        type: object
                        description: "{ group, count, members? } — the panel it routes
                          to after the edit."
                      can_edit:
                        type: boolean
                        description: Whether the CALLER may edit/reopen it — the same
                          manage predicate this endpoint gates on.
                        example: true
                      can_delete:
                        type: boolean
                        description: Whether the CALLER may delete it. Always equal
                          to `can_edit` (one manage right).
                        example: true
                      ideas:
                        type: array
                        description: One page of the campaign's ideas.
                        items:
                          type: object
                      ideas_meta:
                        type: object
                        description: "{ total_count, current_page, per_page, total_pages,
                          sort }."
                  changed:
                    type: boolean
                    description: Whether anything actually moved. False on a no-op
                      save.
                    example: true
                  changed_fields:
                    type: array
                    description: The API field names that changed — `title`, `brief`,
                      `icon`, `color`, `start_date`, `close_date`, `reviewer_group_id`.
                      Empty on a no-op.
                    items:
                      type: string
                    example:
                    - title
                    - close_date
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Ideas app is not accessible (`access_denied`), campaigns
            are turned off for the workspace (`campaigns_disabled`), or the caller
            is neither the campaign''s creator nor an Ideas admin (`forbidden`). Or
            the token lacks `write:ideas` — or, for a close date before today on a
            campaign that is not closed, `destructive:ideas` on top (`insufficient_permissions`,
            #7186); nothing is written.'
        '404':
          description: No campaign with that id in the caller's business (error code
            `not_found`).
        '422':
          description: 'The edit was refused and NOTHING was written. `error.code`
            says which value: `invalid` (the model — blank title, over-length title,
            blank brief, close date before start date), `invalid_icon`, `invalid_color`,
            `invalid_date`, `invalid_reviewer_group`, or `update_failed` (infrastructure).
            Also `content_blocked` when the workspace''s content-moderation policy
            refuses the submitted text; the message is the policy''s own and is safe
            to show the author verbatim.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Ideas
      summary: Delete a campaign
      description: |
        Deletes a campaign — the native mirror of the web
        `Apps::Ideas::CampaignsController#destroy` (the **Delete** action on the
        campaign detail page). Both surfaces run the same
        `::Ideas::CampaignDeletionService`, so neither can leave state the other
        wouldn't.

        **CREATOR OR IDEAS ADMIN.** The campaign's creator may delete it, and so may
        an Ideas admin — a business admin-or-above, or a user holding the Ideas
        app-admin role. This is the web's `require_campaign_manager` rule verbatim,
        and it is deliberately **wider than the author-only idea delete**
        (`DELETE /ideas/{id}`): a creator-only rule stranded campaigns whose creator
        had been deactivated or had left the business, with nobody able to edit,
        close, reopen or delete them. Anyone else gets `403 forbidden` and the
        campaign is untouched. Note that contributing an idea to a campaign grants
        nothing here, and neither does the *"Who can create campaigns"* audience —
        that setting governs creating, not deleting.

        Check **`can_delete`** on `GET /ideas/campaigns/{id}` to decide whether to
        show the affordance: it is the same predicate this endpoint enforces.

        **The campaign's IDEAS SURVIVE.** A campaign is a grouping, not an owner: its
        ideas are detached (`campaign_id` set to `NULL`) and keep everything they had
        — their votes, comments, RICE scores, attachments, audit trail and lifecycle
        stage. They simply reappear in the feed with no campaign. Nothing else in the
        schema references a campaign, so there is no further cascade.
        `ideas_detached` reports how many moved, so a client can confirm the scope it
        warned the user about.

        The whole thing runs inside ONE transaction in a constant number of queries —
        the detach is a single set-based `UPDATE`, so deleting a campaign holding
        1,000 ideas costs what deleting an empty one does. A failure rolls everything
        back: the campaign survives AND its ideas are still attached.

        **Requires campaigns to be enabled**, like every action in this section: when
        the Ideas `campaigns` setting is off the response is `403` with error code
        `campaigns_disabled` (distinct from the app-access `403 access_denied`), and
        it is reported ahead of the narrower `forbidden` so a client hides the whole
        Campaigns tab rather than one button.

        **Not idempotent.** A second call returns `404 not_found`, because the
        campaign is genuinely gone. Clients should treat `404` on a retry as success.

        Delete-level (owner decision 2026-10-06, #6720 / #6721): requires the
        `destructive:ideas` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Campaign deleted; its ideas were kept and detached.
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign_id
                - deleted
                - ideas_detached
                - message
                properties:
                  campaign_id:
                    type: integer
                    description: The id of the campaign that was deleted.
                    example: 12
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  ideas_detached:
                    type: integer
                    description: How many ideas left the campaign and are now uncategorised.
                      The ideas themselves are NOT deleted.
                    example: 14
                  message:
                    type: string
                    example: Campaign deleted. Its ideas were kept and detached.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            campaigns are turned off for the workspace (error code `campaigns_disabled`),
            or the caller is neither the campaign's creator nor an Ideas admin (error
            code `forbidden`). The campaign is left untouched.
        '404':
          description: No campaign with that id in the caller's business (error code
            `not_found`). A campaign belonging to another tenant reports `404`, never
            `403`, so the response can't confirm that the id exists elsewhere. A non-numeric
            id 404s at the router.
        '422':
          description: The delete could not be completed and was rolled back (error
            code `delete_failed`); the campaign survives and its ideas are still attached
            to it.
  "/ideas/campaigns/{id}/reopen":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign to reopen.
      schema:
        type: integer
    patch:
      tags:
      - Ideas
      summary: Reopen a closed campaign for submissions
      description: |
        Reopens a CLOSED campaign so members can post ideas to it again — the native
        mirror of the web "Reopen campaign" action (`Apps::Ideas::CampaignsController#reopen`,
        the Manage menu on the campaign detail, which offers Reopen exactly when the
        campaign's phase is `closed`). Both surfaces call the same
        `::Ideas::Campaign#reopen`, so neither can drift from the other.

        **One reopen writes two things:**

        1. the manual `closed` flag is cleared;
        2. a close date that has **already passed** is dropped. Without this second
           part a date-expired campaign would fall straight back to `closed` the
           moment it was reopened. A close date still in the FUTURE is left alone.

        Which happened is reported as `close_date_cleared`, because a client whose card
        is showing an end date needs to know the campaign no longer has one.

        **Authorization: the campaign's CREATOR, or an Ideas ADMIN** (a business
        admin-or-above, or a user holding the Ideas app-admin role) — the web's
        `require_campaign_manager` rule verbatim. Anyone else gets `403 forbidden`.
        The admin half is deliberate: creator-only stranded campaigns whose creator
        had been deactivated or had left, with nobody able to reopen them. Sitting on
        the campaign's **review panel grants nothing here** — reviewing ideas and
        managing the drive that collects them are different rights.

        Managing rights are reported on the campaign detail (`can_edit` on
        `GET /ideas/campaigns/{id}` is the same creator-or-admin predicate this gate
        applies), so a client can offer Reopen exactly when `status` is `closed` AND
        the caller holds that right, rather than tapping into a 403.

        **A campaign that isn't closed is a `200` no-op** (`changed: false`) that
        writes nothing — never an error — so a retried or double-tapped reopen is
        safe. Key off `changed`, not the status code. `message` says which no-op it
        was: "already open for submissions" for an open campaign, and when submissions
        actually open for a SCHEDULED one (whose submissions genuinely haven't opened
        yet).

        **A reopened campaign whose START date is still ahead comes back as
        `status: "scheduled"`, not `"open"`** — `status` is the derived phase
        (`Campaign#phase`), and its submissions legitimately open on that date. Read
        it back rather than assuming `"open"`.

        There is no request body: the campaign is identified by the path, and reopening
        takes no options. The response carries the campaign in the **same row shape the
        campaigns LIST returns**, so a client can drop it straight into the card or
        header it just acted on. Nothing about the campaign's ideas, review panel or
        roster changes when it reopens, so the heavier detail payload is not used —
        call `GET /ideas/campaigns/{id}` if the full detail is wanted.

        Gated on the `campaigns` setting like every campaign endpoint: a workspace with
        campaigns turned off answers `403 campaigns_disabled`, exactly as the web
        redirects the whole section away.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The campaign is open (or scheduled to open). `changed` distinguishes
            a reopen that just happened from a no-op on a campaign that wasn't closed.
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                - changed
                - close_date_cleared
                - message
                properties:
                  changed:
                    type: boolean
                    description: "`true` when this call reopened the campaign. `false`
                      when it was not closed and nothing was written."
                    example: true
                  close_date_cleared:
                    type: boolean
                    description: "`true` when the campaign's close date had already
                      passed and was therefore dropped (`end_date` is now `null`).
                      `false` for a future close date, which is kept, and for a no-op."
                    example: false
                  message:
                    type: string
                    description: Human-readable outcome — `Campaign reopened for submissions.`,
                      or on a no-op why nothing changed.
                    example: Campaign reopened for submissions.
                  campaign:
                    type: object
                    description: The campaign in the same row shape `GET /ideas/campaigns`
                      returns.
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase after the reopen — `scheduled`
                          when the start date is still ahead, otherwise `open`. Never
                          the stored status enum.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: open
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; null when the campaign has no start
                          date.
                        example: '2026-07-21'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; `null` when the campaign is open-ended
                          — including when this call just cleared an expired close
                          date (see `close_date_cleared`).
                        example: '2026-08-20'
                      icon:
                        type: string
                        description: Font Awesome glyph name with no `fa-` prefix
                        coerced to the authored set (default `bullhorn`).:
                        example: rocket
                      color:
                        type: string
                        description: Palette key
                        whitelist-coerced (default `blue`).:
                        example: forest
                      color_hex:
                        type: string
                        description: The palette key's primary hex
                        so a client can render without shipping the palette.:
                        example: "#2d6a4f"
                      has_my_idea:
                        type: boolean
                        description: Whether the CALLING user has authored at least
                          one idea in this campaign.
                        example: true
                      ideas_count:
                        type: integer
                        description: Ideas submitted to this campaign — unchanged
                          by a reopen.
                        example: 14
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), campaigns
            are turned off for the workspace (`campaigns_disabled`), or the caller
            is neither the campaign's creator nor an Ideas admin (`forbidden`). Review-panel
            members are NOT exempt.
        '404':
          description: No campaign with that id in the caller's business (`not_found`).
            A non-numeric id 404s at the router.
        '422':
          description: "`reopen_failed` — the campaign could not be saved (the message
            carries the model's own validation sentence). Nothing was written."
  "/ideas/campaigns/{id}/close":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign to close.
      schema:
        type: integer
    patch:
      tags:
      - Ideas
      summary: Close a campaign to new submissions
      description: |
        Closes a campaign so members can no longer post ideas to it — the native mirror
        of the web "Close campaign" action (`Apps::Ideas::CampaignsController#close`, the
        Manage menu on the campaign detail, which offers Close exactly when the
        campaign's phase is NOT `closed`). The twin of
        `PATCH /ideas/campaigns/{id}/reopen`; both surfaces call the same
        `::Ideas::Campaign#close`, so a close is identical wherever it comes from.

        **Its ideas survive, untouched.** Closing pauses NEW submissions only: every
        idea already collected stays open for voting, comments, RICE scoring and stage
        moves, and keeps appearing in the feed. This is **not** a delete and **not** an
        archive — use `DELETE /ideas/campaigns/{id}` if you mean to remove the campaign
        (which detaches its ideas rather than deleting them).

        **`end_date` is deliberately left alone.** A campaign closed EARLY keeps the
        close date it advertised; only the manual closed flag is written. The campaigns
        list reads the record's `updated_at` (which this call touches) as the end
        timestamp for exactly that case, so nothing needs the date rewritten — and
        rewriting it would silently change what every card says about the campaign.

        **Authorization: the campaign's CREATOR, or an Ideas ADMIN** (a business
        admin-or-above, or a user holding the Ideas app-admin role) — the web's
        `require_campaign_manager` rule verbatim. Anyone else gets `403 forbidden`.
        Sitting on the campaign's **review panel grants nothing** — reviewing ideas and
        managing the drive that collects them are different rights.

        **A SCHEDULED campaign can be closed** (the web offers Close for it too) — that
        is cancelling a drive before it ever opens. Its `start_date` is not rewritten,
        and it comes back `status: "closed"`.

        **An already-closed campaign is a `200` no-op** (`changed: false`) that writes
        nothing, so a retried or double-tapped close is safe. Key off `changed`, not the
        status code. `message` distinguishes the two ways a campaign is already closed,
        because they are different facts: closed by hand ("This campaign is already
        closed.") versus closed by its close date passing ("This campaign already closed
        on <date>."). In the second case the stored status stays `open` — writing the
        flag would change nothing a client can see.

        There is no request body: the campaign is identified by the path, and closing
        takes no options. The response carries the campaign in the **same row shape the
        campaigns LIST returns**, review panel included, so a client can drop it straight
        into the card or header it just acted on.

        Gated on the `campaigns` setting like every campaign endpoint: a workspace with
        campaigns turned off answers `403 campaigns_disabled`.

        Delete-level (owner decision 2026-10-06, #6720 / #6721 — a close that
        `/reopen` takes back still asks the delete scope, #6768): requires the
        `destructive:ideas` scope. Reopening asks `write:ideas`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The campaign is closed. `changed` distinguishes a close that
            just happened from a no-op on a campaign that was already closed.
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign
                - changed
                - message
                properties:
                  changed:
                    type: boolean
                    description: "`true` when this call closed the campaign. `false`
                      when it was already closed and nothing was written."
                    example: true
                  message:
                    type: string
                    description: Human-readable outcome — `Campaign closed.`, or on
                      a no-op which kind of already-closed it was.
                    example: Campaign closed.
                  campaign:
                    type: object
                    description: The campaign in the same row shape `GET /ideas/campaigns`
                      returns.
                    required:
                    - id
                    - title
                    - description
                    - status
                    - start_date
                    - end_date
                    - icon
                    - color
                    - color_hex
                    - has_my_idea
                    - ideas_count
                    - reviewers
                    properties:
                      id:
                        type: integer
                        example: 12
                      title:
                        type: string
                        example: Q4 Cost Savings
                      description:
                        type: string
                        nullable: true
                        description: The campaign brief.
                        example: Ideas to cut operating cost this quarter.
                      status:
                        type: string
                        description: The DERIVED phase after the close — `closed`,
                          including for a campaign that was still `scheduled` beforehand.
                        enum:
                        - open
                        - scheduled
                        - closed
                        example: closed
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601; never rewritten by a close.
                        example: '2026-07-21'
                      end_date:
                        type: string
                        format: date
                        nullable: true
                        description: ISO-8601, and **unchanged by this call** — a
                          campaign closed early keeps the close date it advertised.
                          `null` only when the campaign was already open-ended.
                        example: '2026-08-20'
                      icon:
                        type: string
                        description: Font Awesome glyph name with no `fa-` prefix
                        coerced to the authored set (default `bullhorn`).:
                        example: rocket
                      color:
                        type: string
                        description: Palette key
                        whitelist-coerced (default `blue`).:
                        example: forest
                      color_hex:
                        type: string
                        description: The palette key's primary hex
                        so a client can render without shipping the palette.:
                        example: "#2d6a4f"
                      has_my_idea:
                        type: boolean
                        description: Whether the CALLING user has authored at least
                          one idea in this campaign.
                        example: true
                      ideas_count:
                        type: integer
                        description: Ideas submitted to this campaign — unchanged
                          by a close; they are not deleted or detached.
                        example: 14
                      reviewers:
                        type: object
                        description: The campaign's review panel, in the same `{ group,
                          count, members? }` shape the list and the detail report.
                          `members` is capped at 3 and omitted entirely while the
                          `reviewer_names` setting is off.
                        required:
                        - group
                        - count
                        properties:
                          group:
                            type: object
                            nullable: true
                            required:
                            - id
                            - name
                            properties:
                              id:
                                type: integer
                                example: 44387
                              name:
                                type: string
                                example: Product Council
                          count:
                            type: integer
                            description: The panel's FULL member count
                            not `members.size`.:
                            example: 12
                          members:
                            type: array
                            maxItems: 3
                            items:
                              type: object
                              required:
                              - id
                              - name
                              - photo
                              properties:
                                id:
                                  type: integer
                                  example: 63
                                name:
                                  type: string
                                  nullable: true
                                  example: Grace Dalton
                                photo:
                                  type: string
                                  example: https://…/avatar.jpg
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), campaigns
            are turned off for the workspace (`campaigns_disabled`), or the caller
            is neither the campaign's creator nor an Ideas admin (`forbidden`). Review-panel
            members are NOT exempt.
        '404':
          description: No campaign with that id in the caller's business (`not_found`).
            A non-numeric id 404s at the router.
        '422':
          description: "`close_failed` — the campaign could not be saved (the message
            carries the model's own validation sentence). Nothing was written."
  "/ideas/campaigns/{id}/reviewers":
    parameters:
    - name: id
      in: path
      required: true
      description: The campaign id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: A campaign's reviewer roster
      description: |
        The people who review this campaign's ideas — the members of the panel the
        campaign routes to: **its own panel when it names one, else the workspace
        default**. That is the identical resolution the web campaign detail
        (`Apps::Ideas::CampaignsController#show`), the campaigns index card and
        `Ideas::CampaignDetailQuery` apply, so this roster can never disagree with
        them about whose panel it is.

        This is the **full-list twin** of the capped `reviewers.members` avatar
        preview on `GET /ideas/campaigns/{id}` — exactly the relationship
        `GET /ideas/{idea_id}/reviewers` has to the idea detail. Render the stack
        from the detail, open this for the complete, filterable list. The two agree
        by construction: same panel, same order, and the detail's `reviewers.count`
        equals this endpoint's `meta.total_count`.

        **Ordering** is `users.id` ascending — the order the web campaign detail
        renders its roster in (its `where(id: member_ids)` carries no `ORDER BY`),
        and deterministic, so paging never skips or repeats a reviewer.

        **Search** (`q`) matches the term case-insensitively against each reviewer's
        name (name / first / last) and email — the server-side equivalent of the
        web panel's client-side filter. `%` and `_` are matched literally, and
        `meta.total_count` narrows to the matches while the `panel` node does not.

        **Access** is every member who can open the campaign: seeing who reviews it
        is not a manage right, matching the web detail, which shows the panel to
        every reader. Three gates apply, in this order:
        * the Ideas app must be accessible (`403 access_denied`);
        * campaigns must be enabled (`403 campaigns_disabled`) — the whole section,
          like every campaign endpoint;
        * the `reviewer_names` setting must be ON (`403 reviewer_names_hidden`), the
          same gate the idea roster carries. It is checked BEFORE the campaign
          lookup, so a workspace that hides names answers the same 403 for a
          campaign that does not exist — the gate cannot be used to probe for ids.

        `panel` names the group the roster belongs to (title the screen "Reviewed by
        <panel>"), or `null` when the campaign names no panel AND the workspace has
        no default — then `reviewers` is empty and `meta.total_count` is 0. A
        `reviewer_group_id` pointing at another tenant's group resolves to `null`
        the same way: panel lookup is business-scoped, so it can never roster
        somebody else's people.

        **Query budget: constant** — one cached membership lookup, one COUNT, and one
        page of users with the whole avatar-variant chain preloaded. Neither the
        panel's size nor `per_page` adds a query.
      parameters:
      - name: q
        in: query
        required: false
        description: Free-text search over reviewer name / email.
        schema:
          type: string
        example: alice
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1). A page past the end is an empty
          200.
        schema:
          type: integer
          minimum: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, clamped to 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
        example: 20
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Campaign reviewer roster retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - campaign_id
                - reviewers
                - panel
                - meta
                properties:
                  campaign_id:
                    type: integer
                    description: The campaign the roster belongs to, echoed so a client
                      can match the response to the card it opened.
                    example: 12
                  reviewers:
                    type: array
                    description: The panel members, `users.id`-ordered, one page at
                      a time.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - photo
                      properties:
                        id:
                          type: integer
                          description: The reviewer's user id.
                          example: 334
                        name:
                          type: string
                          description: Display name (full_name
                          falling back to name).:
                          example: Alice Anderson
                        photo:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (request host prepended
                            to a relative path; ui-avatars fallback otherwise).
                          example: https://ui-avatars.com/api/?name=Alice%20Anderson&size=40&background=random
                  panel:
                    type: object
                    nullable: true
                    description: The review panel (NotificationRecipientGroup) the
                      roster belongs to — the campaign's own group, else the workspace
                      default. Null when neither is configured (or the stored group
                      belongs to another tenant), in which case `reviewers` is empty.
                    required:
                    - id
                    - name
                    properties:
                      id:
                        type: integer
                        example: 44387
                      name:
                        type: string
                        example: Campaign Crew
                  meta:
                    type: object
                    required:
                    - total_count
                    - current_page
                    - per_page
                    - total_pages
                    properties:
                      total_count:
                        type: integer
                        description: Reviewers matching the active search — the panel's
                          FULL size when `q` is blank, computed on the unpaginated
                          relation, so it is unaffected by `per_page`. Equals the
                          campaign detail's `reviewers.count`.
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        description: 0 when the roster is empty.
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (`access_denied`),
            campaigns are turned off for the workspace (`campaigns_disabled`), or
            reviewer names are hidden (`reviewer_names_hidden` — reported even for
            an unknown campaign id, since the gate precedes the lookup).
        '404':
          description: No campaign with that id in the caller's business (`not_found`).
            A campaign belonging to another tenant reports `404`, never `403`; a non-numeric
            id 404s at the router.
  "/ideas/{idea_id}/reviewers":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: An idea's reviewer roster
      description: |
        The people who can review this idea — the members of the review panel it
        routes to (its campaign's panel when the campaign sets one, else the
        workspace default panel). The native mirror of the web idea detail's
        "Review Panel" modal (Apps::Ideas::IdeasController#show), backed by the
        shared `::Ideas::ReviewerRosterQuery` + the same panel resolution the web
        uses, so the roster can't drift between surfaces.

        **Ordering** is `users.id` ascending — the same stable order the web modal
        renders, and deterministic so pagination never skips or repeats a
        reviewer.

        **Search** (`q`) matches the term (case-insensitively) against each
        reviewer's name and email — the server-side equivalent of the web modal's
        "Search reviewers" box.

        **Access** requires the `reviewer_names` setting to be ON. When an admin
        has turned reviewer names off, the web shows only the panel's group name;
        this endpoint returns `403 reviewer_names_hidden` so the client can hide
        the roster affordance rather than tap into an error.

        `panel` names the group the roster belongs to (the "Reviewed by …" line),
        or `null` when the idea has no panel configured (then `reviewers` is empty
        and `meta.total_count` is 0). `meta.total_count` also drives the
        "N reviewers" header.
      parameters:
      - name: q
        in: query
        required: false
        description: Free-text search over reviewer name / email.
        schema:
          type: string
        example: alice
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, clamped to 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
        example: 20
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Reviewer roster retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - reviewers
                - panel
                - meta
                properties:
                  reviewers:
                    type: array
                    description: The panel members, id-ordered, one page at a time.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - photo
                      properties:
                        id:
                          type: integer
                          description: The reviewer's user id.
                          example: 334
                        name:
                          type: string
                          description: Display name (full_name
                          falling back to name).:
                          example: Alice Anderson
                        photo:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (request host prepended
                            to a relative path; ui-avatars fallback otherwise).
                          example: https://ui-avatars.com/api/?name=Alice%20Anderson&size=40&background=random
                  panel:
                    type: object
                    nullable: true
                    description: The review panel (NotificationRecipientGroup) the
                      roster belongs to; null when the idea has no panel configured.
                    required:
                    - id
                    - name
                    properties:
                      id:
                        type: integer
                        example: 44387
                      name:
                        type: string
                        example: Idea Reviewers
                  meta:
                    type: object
                    required:
                    - total_count
                    - current_page
                    - per_page
                    - total_pages
                    properties:
                      total_count:
                        type: integer
                        description: Total reviewers matching the active search (the
                          whole panel when q is blank).
                        example: 8
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Ideas app is not accessible (`access_denied`), or
            reviewer names are turned off for the workspace (`reviewer_names_hidden`).
        '404':
          description: No idea with that id in the caller's business (`not_found`).
  "/ideas/{idea_id}/voters":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: The users who upvoted an idea
      description: |
        The people who upvoted this idea. The native mirror of the web idea
        detail's "See who upvoted" modal
        (`Apps::Ideas::IdeasController#voters`), backed by the shared
        `::Ideas::VoterListQuery`, so the two surfaces can't drift in ordering, in
        what a search term matches, or in who is listed at all.

        **POSITIVE votes only.** A row is an `up` vote, plus any legacy `yes`
        from the retired thumbs model (which still counts as support). A legacy
        `no` is never listed: there is no down-vote data model and no
        down-voter list.

        **Ordering** is newest vote first (`voted_on` descending), with a
        **`id` descending tiebreak**. The tiebreak is load-bearing, not
        decoration — votes seeded in bulk share one timestamp, and a tied set has
        no inherent order, so without it a page boundary could repeat or skip a
        voter the user already scrolled past. Paging is therefore stable across
        repeated requests.

        **Search** (`q`) matches the term as a case-insensitive substring of the
        voter's name — the server-side equivalent of the web modal's
        "Search by name…" box. It matches mid-word, and `%` / `_` are matched
        literally rather than as SQL wildcards. Blank/absent is a no-op. The
        applied term is echoed back as `query` (trimmed, `""` when not
        searching). A term that matches nothing is a normal `200` with an empty
        `voters` array — never an error.

        **Two different totals, deliberately.** `meta.total_count` is the
        SEARCHED total and is what pagination is over. `total_votes` is the
        idea's UNFILTERED positive-vote total, so a client can keep showing the
        real "N votes" headline while the user filters the list — it matches the
        vote count on the idea card and detail.

        `id` on each row is the **USER's** id, not the vote row's: it is what a
        client needs to open that person's profile, which is what tapping a voter
        does in the mobile design. The vote has no client-facing identity here.

        `photo` is **never null** — the platform falls back to a generated
        initials avatar when the user has no profile photo, so a client needs no
        placeholder branch. `is_you` flags the calling user's own row, driving the
        "You" pill next to their name.
      parameters:
      - name: q
        in: query
        required: false
        description: Case-insensitive substring match on the voter's name. Matches
          mid-word; `%` and `_` are literal. Blank/absent disables the search. Narrows
          `meta.total_count` but NOT `total_votes`.
        schema:
          type: string
        example: kejriwal
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1). An out-of-range page returns
          an empty list.
        schema:
          type: integer
          minimum: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, clamped to 1..50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Voters retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - voters
                - query
                - idea_id
                - total_votes
                - meta
                properties:
                  voters:
                    type: array
                    description: One row per positive vote, newest first.
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - photo
                      - voted_on
                      - is_you
                      properties:
                        id:
                          type: integer
                          description: The USER's id (not the vote row's) — use it
                            to open the person's profile.
                          example: 455
                        name:
                          type: string
                          nullable: true
                          description: The voter's full name.
                          example: Amaya Kejriwal
                        photo:
                          type: string
                          description: Absolute avatar URL. Never null — falls back
                            to a generated initials image when the user has no profile
                            photo.
                          example: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/…/avatar.jpg
                        voted_on:
                          type: string
                          format: date-time
                          nullable: true
                          description: ISO-8601 timestamp of when this user upvoted.
                          example: '2026-07-20T10:00:00Z'
                        is_you:
                          type: boolean
                          description: True on the CALLING user's own row — drives
                            the "You" pill. At most one row per response.
                          example: false
                  query:
                    type: string
                    description: The search term actually applied, trimmed. Empty
                      string when not searching.
                    example: kejriwal
                  idea_id:
                    type: integer
                    description: Echo of the idea these voters belong to.
                    example: 4
                  total_votes:
                    type: integer
                    description: The idea's UNFILTERED positive-vote total — unchanged
                      by `q`, so the "N votes" headline stays truthful while filtering.
                      Matches the vote count on the idea card.
                    example: 9
                  meta:
                    type: object
                    description: Pagination over the SEARCHED set.
                    properties:
                      total_count:
                        type: integer
                        description: Voters matching the search.
                        example: 9
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        description: 0 when nothing matches.
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
  "/ideas/{idea_id}/stage":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea to move.
      schema:
        type: integer
    patch:
      tags:
      - Ideas
      summary: Move an idea to a lifecycle stage
      description: |
        Moves an idea to another lifecycle stage — the native mirror of the web
        reviewer action (`Apps::Ideas::IdeasController#stage`: the stage picker on
        the idea detail, and the Kanban board's drag-to-move). Both surfaces, plus
        the Ideas agent's `move_stage` tool, run the SAME
        `::Ideas::StageTransitionService`, so a move is identical wherever it comes
        from.

        **One move writes four things, in one transaction** — if any fails, none
        of it sticks (an idea that moved with no audit trail is exactly what the
        transaction prevents):

        1. the idea's stage;
        2. an **audit entry** recording who moved it, from where, to where, and the
           note — the reviewer accountability trail shown on the idea detail;
        3. the `note`, if given, as a **real comment on the idea's Discussion**, so
           every member — not just reviewers — can see why it moved;
        4. `declined_from_stage_id`, **only** when the target stage's category is
           `declined` — the off-ramp the detail page's Lifecycle card reads to say
           which stage the idea fell out of. It is left untouched by any other move.

        A stage-change **notification** is then sent to the idea's author (outside
        the transaction, and subject to the workspace's `notify_stage_change`
        setting), so a delivery failure can never undo a committed move.

        **Authorization: review-panel membership on THIS idea, with NO admin
        bypass** — the web rule verbatim. The panel is the idea's campaign's
        reviewer group when its campaign sets one, else the workspace default
        panel. An Ideas admin who is not on that panel gets `403`, and so does the
        idea's own author. `GET /ideas/config` reports the panel
        (`idea_reviewers`), and `GET /ideas/{id}` reports the idea's own, so a
        client can hide the stage picker rather than tapping into a 403.

        **Already in the target stage** is a `200` with `changed: false` and
        **nothing written** — no audit entry, no duplicate note comment, no
        notification. The call is therefore idempotent and safe to retry. (The web
        action answers its Kanban caller with a bare `204` here; this endpoint
        returns a body because a native client needs the idea's current state
        either way, and every other endpoint on this surface returns one.)

        There is **no stage list here** — `GET /ideas/config` already returns the
        workspace's ordered pipeline in the same stage shape, which is what
        populates a stage picker.

        **Scope.** A move asks `write:ideas`. A move INTO a stage whose `category`
        is `declined` (the default pipeline's "Deferred") declines the idea, a
        delete-level act (owner decision 2026-10-06, #6720 / #6721): it requires
        the `destructive:ideas` scope on top of `write:ideas`, as the Ideas
        agent's `move_stage` does for the same move. A missing or unknown
        `stage_id` asks `write:ideas` and is answered `422` as below.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - stage_id
              properties:
                stage_id:
                  type: integer
                  description: The target stage. Must be a lifecycle stage of the
                    caller's own business — an unknown id, a non-numeric one, or another
                    tenant's stage is `422 unknown_stage` and moves nothing.
                  example: 412
                note:
                  type: string
                  description: Optional rationale. Recorded on the audit entry AND
                    posted to the idea's Discussion as a comment by the caller. Blank/whitespace
                    is treated as absent (nothing is posted).
                  example: Moving to Planned — costed and scheduled for Q4.
          multipart/form-data:
            schema:
              type: object
              required:
              - stage_id
              properties:
                stage_id:
                  type: integer
                  example: 412
                note:
                  type: string
                  example: Moving to Planned.
      responses:
        '200':
          description: The idea is in the target stage. `changed` distinguishes a
            move that just happened from a no-op (it was already there).
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - changed
                - stage
                - previous_stage
                - note_posted
                - message
                properties:
                  idea_id:
                    type: integer
                    example: 42
                  changed:
                    type: boolean
                    description: "`true` when this call moved the idea (audit entry
                      written, note posted if given, author notified). `false` when
                      it was already in that stage and nothing was written."
                    example: true
                  confirmed:
                    type: boolean
                    description: "`true` when a reviewer picked the decided stage
                      the idea's AUTHOR had moved it to — the panel's review is now
                      recorded (audit entry written, the panel's asks closed) though
                      `changed` is false and `message` reads `Confirmed “<stage>”.`
                      A retry of that is the plain no-op (`confirmed: false`)."
                    example: false
                  stage:
                    allOf:
                    - "$ref": "#/components/schemas/IdeaLifecycleStage"
                    description: The stage the idea is in now.
                  previous_stage:
                    allOf:
                    - "$ref": "#/components/schemas/IdeaLifecycleStage"
                    description: The stage it came from. `null` if the idea's previous
                      stage row no longer exists. On a no-op this is the same stage
                      as `stage`.
                  declined_from_stage_id:
                    type: integer
                    nullable: true
                    description: The idea's current off-ramp pointer — the stage it
                      was declined FROM, which the detail page's Lifecycle card reads.
                      A move into a `declined` stage sets it; nothing clears it if
                      the idea is later revived, so a previously-deferred idea still
                      reports it after moving back out. `null` for an idea never declined.
                    example:
                  note_posted:
                    type: boolean
                    description: Whether a note was actually added to the Discussion.
                    example: true
                  message:
                    type: string
                    description: Human-readable outcome, e.g. `Moved to “Planned”.`
                      or `Already in “Planned”.`
                    example: Moved to “Planned”.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Ideas app is not accessible (`access_denied`), or
            the caller is not a member of this idea's review panel (`forbidden`).
            Admins are NOT exempt — panel membership is the only grant.
        '404':
          description: No idea with that id in the caller's business (`not_found`).
        '422':
          description: "`stage_id_required` when it is missing; `unknown_stage` when
            it is not a lifecycle stage of this business; `stage_change_failed` when
            the transaction could not be committed (nothing moved)."
  "/ideas/{idea_id}/vote":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    post:
      tags:
      - Ideas
      summary: Cast the caller's upvote on an idea
      description: |
        Records the caller's single upvote (Ideas is upvote-only — one vote per
        user per idea). The native mirror of the web
        Apps::Ideas::VotesController#create.

        * **Idempotent** — voting again is a no-op that still returns `200` with
          the current state (the DB is unique on user + idea).
        * Refused with `422 voting_closed` once the idea's voting-close date has
          passed — only on a workspace whose `close_date_enabled` is true. Where
          that feature is off the date is inert and the vote is accepted.

        Returns the idea's resulting vote state. `vote_count` / `has_voted` use
        the SAME key names the feed card does, so a client can patch its cached
        card in place; `vote_change_allowed` tells the client whether to show a
        remove-vote affordance for this idea (false once its voting has closed).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Vote recorded (or already present).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/IdeaVoteState"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
        '422':
          description: Voting has closed for this idea (error code `voting_closed`).
    delete:
      tags:
      - Ideas
      summary: Remove the caller's upvote from an idea
      description: |
        Removes the caller's upvote. The native mirror of the web
        Apps::Ideas::VotesController#destroy.

        **Refused once voting is locked for the idea** — the workspace has
        voting close dates on and the idea's `voting_closes_on` has passed: this
        endpoint returns `422 voting_closed` and the vote is kept, exactly as
        casting one is refused. A vote taken off a closed idea could not be put
        back. Checked first, so it is the answer even when the next gate would
        also refuse.

        **Gated by the "Allow removing an upvote" workspace setting
        (`vote_change`).** When that setting is OFF the upvote is final: this
        endpoint returns `403 vote_change_disabled` and the vote is kept. Both
        gates are checked BEFORE any delete, so they apply even when the caller
        has no vote to remove. Otherwise removal is **idempotent** — removing
        with no vote present is a no-op `200`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Vote removed (or none was present).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/IdeaVoteState"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR removing an upvote is disabled for the workspace (error code `vote_change_disabled`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
        '422':
          description: Voting has closed for this idea (error code `voting_closed`).
  "/ideas/{idea_id}/score":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea being scored.
      schema:
        type: integer
        example: 91
    patch:
      tags:
      - Ideas
      summary: Add or update an idea's RICE score (reviewer)
      description: |
        Writes the four RICE inputs — **Reach, Impact, Confidence, Effort** — onto an
        idea, the native mirror of the web reviewer's Stage-Management score form
        (`Apps::Ideas::IdeasController#score`). Both surfaces call the same
        `::Ideas::ScoreUpdateService`, so the coercion, the effort floor and the
        audit trail cannot drift.

        **Gates — identical to the web, in this order:**
        1. The Ideas app must be accessible to the caller → `403 access_denied`.
        2. RICE scoring must be enabled for the workspace → `403 scoring_disabled`.
        3. The caller must be on **this idea's review panel** (its campaign's group
           when it has one, else the workspace default) → `403 not_a_reviewer`.
           **Panel membership is the only grant** — an Ideas admin who is not on the
           panel is refused, exactly as on the web.
        4. The idea must exist in the caller's business → `404 not_found` (a foreign
           id is indistinguishable from one that never existed).

        **Field rules**
        * `reach` is stored as an integer; `impact`, `confidence` and `effort` as floats.
        * `effort` is floored at **0.25** so RICE never divides by zero — but only
          when a value is actually supplied.
        * Sending a field **empty** CLEARS it (stores null); **omitting** a field
          leaves the stored value untouched (partial update).
        * `confidence` is optional — when null, RICE treats it as `1.0`.

        **RICE only computes when Reach, Impact and Effort are all present.** When one
        is blank the values still save but the idea stays unscored: the response
        returns `scored: false` and `rice_score: null`, and `message` says so rather
        than implying a score landed.

        `rice_score = round(reach × impact × confidence ÷ max(effort, 0.25))`

        **Idempotent** — re-sending the same values is a no-op save, and the audit
        logger coalesces a reviewer's rapid successive edits into a single entry
        (dropping it entirely if they revert to where the session started).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - idea
              properties:
                idea:
                  type: object
                  description: 'The RICE inputs. All four are optional: omit a field
                    to leave it unchanged, send it empty to clear it.'
                  properties:
                    reach:
                      type: integer
                      nullable: true
                      description: How many people this affects (stored as an integer).
                      example: 100
                    impact:
                      type: number
                      nullable: true
                      description: Impact multiplier per person.
                      example: 2
                    confidence:
                      type: number
                      nullable: true
                      description: Confidence factor (0–1). Optional — null is treated
                        as 1.0.
                      example: 0.5
                    effort:
                      type: number
                      nullable: true
                      description: Person-months of effort. Floored at 0.25 when supplied.
                      example: 2
      responses:
        '200':
          description: Score saved (see `scored` for whether RICE could be computed)
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - rice_score
                - rice_score_band
                - scored
                - score
                - message
                properties:
                  idea_id:
                    type: integer
                    example: 91
                  rice_score:
                    type: integer
                    nullable: true
                    description: The computed RICE score, or null when a required
                      input (reach / impact / effort) is blank.
                    example: 50
                  rice_score_band:
                    type: object
                    description: 'The colour band `rice_score` renders as, so a client
                      paints the score pill the same way the web does without hardcoding
                      our ranges. `tier` is the stable key to switch on: `high` (>=
                      700), `medium` (>= 300), `low` below that, `none` when the values
                      saved but RICE could not be computed. Always present here —
                      this endpoint 403s outright when RICE scoring is off — so a
                      client can recolour the pill straight off this response instead
                      of refetching the row.'
                    properties:
                      tier:
                        type: string
                        enum:
                        - high
                        - medium
                        - low
                        - none
                        example: high
                      label:
                        type: string
                        description: Human label for the band.
                        example: High
                      color:
                        type: string
                        description: Foreground hex the web uses.
                        example: "#146c43"
                      background:
                        type: string
                        description: Background hex the web uses; `transparent` for
                          the `none` band.
                        example: "#d1f0e0"
                  scored:
                    type: boolean
                    description: False when the values saved but the idea remains
                      unscored — it is then invisible to the score sort and the Value/Effort
                      matrix.
                    example: true
                  confirmed:
                    type: boolean
                    description: "`true` when these values confirmed the score the
                      idea's AUTHOR set — a reviewer's first save of the same values
                      records the panel's review (one audit entry) and `message` reads
                      `Score confirmed.`"
                    example: false
                  score:
                    type: object
                    description: The stored inputs after coercion (nulls for cleared
                      fields).
                    required:
                    - reach
                    - impact
                    - confidence
                    - effort
                    properties:
                      reach:
                        type: integer
                        nullable: true
                        example: 100
                      impact:
                        type: number
                        nullable: true
                        example: 2
                      confidence:
                        type: number
                        nullable: true
                        example: 0.5
                      effort:
                        type: number
                        nullable: true
                        example: 2
                  message:
                    type: string
                    description: '"Score updated." when RICE computed, otherwise an
                      explanation that Reach, Impact and Effort are all required.'
                    example: Score updated.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: The `idea` object was missing from the body (`parameter_missing`).
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible (`access_denied`), RICE scoring
            is turned off for the workspace (`scoring_disabled`), or the caller is
            not on this idea's review panel (`not_a_reviewer` — no admin bypass).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
        '422':
          description: The score could not be saved (`score_failed`).
  "/ideas/{idea_id}/comments":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea id.
      schema:
        type: integer
    get:
      tags:
      - Ideas
      summary: An idea's discussion thread
      description: |
        A paginated list of the idea's **top-level** comments, each inlining its
        direct replies. Threading is ONE level deep, matching the web idea detail
        (`Apps::Ideas::IdeasController#show`) — a reply always has `replies: []`.
        Modelled on the Wikis comment API.

        * **Ordering** — oldest first, like the web thread, with an `id` tiebreak
          so comments sharing a `created_at` never repeat or get skipped across
          page boundaries.
        * **`meta.total_count` counts TOP-LEVEL comments** — that is what
          pagination is over. **`total_comments`** is the full discussion volume
          (comments *plus* replies), which is the number the feed card's
          `comments_count` shows, so a client's header keeps agreeing with the
          card it came from.
        * **Inlined `replies` are capped at 20 per comment.** `per_page` bounds the
          TOP-LEVEL list only, so without a cap one response could carry 50 threads
          × every reply they have (~1.7 MB). `replies_count` is the TRUE total from
          a COUNT — it can exceed `replies.length` — and `replies_truncated` says
          the array is partial. Fetch the rest in *replies mode* (next bullet); its
          default `per_page` is the same 20, so `page=2` continues exactly where
          the inlined array stopped, with no overlap and no gap.
        * **`parent_comment_id`** switches to *replies mode*: pass a top-level
          comment's id to page through that comment's replies instead of the
          thread. An id that is unknown, names a reply, or belongs to a different
          idea returns an empty page (not an error) — the parent is resolved
          through this idea's own comments, so a forged id can never widen the
          result.
        * Soft-deleted comments never appear, and are excluded from both counts.
        * Page size defaults to 20 (max 50). The web renders a fixed 25 per page;
          this surface exposes client-controllable paging like every other Ideas
          endpoint, while using the identical ordering.

        Reading needs no permission beyond access to the Ideas app; a foreign or
        missing idea id returns `404`.
      security:
      - BearerAuth: []
      parameters:
      - name: parent_comment_id
        in: query
        required: false
        description: When present, return the REPLIES of this top-level comment instead
          of the top-level thread.
        schema:
          type: integer
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, maximum 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Thread retrieved successfully.
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - comments
                - total_comments
                - meta
                properties:
                  idea_id:
                    type: integer
                    description: The idea id (echoes the path id).
                    example: 42
                  comments:
                    type: array
                    description: Top-level comments (or replies, in replies mode),
                      oldest first.
                    items:
                      "$ref": "#/components/schemas/IdeaComment"
                  total_comments:
                    type: integer
                    description: The idea's full discussion volume — comments plus
                      replies, excluding soft-deleted rows. Matches the feed card's
                      `comments_count`, and is never below `meta.total_count`.
                    example: 9
                  meta:
                    type: object
                    required:
                    - total_count
                    - current_page
                    - per_page
                    - total_pages
                    properties:
                      total_count:
                        type: integer
                        description: TOP-LEVEL comments (what pagination is over),
                          or replies in replies mode.
                        example: 4
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        description: 0 when the thread is empty.
                        example: 1
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
    post:
      tags:
      - Ideas
      summary: Post a comment or reply on an idea
      description: |
        Posts a comment on an idea, or a **reply** when `parent_comment_id` is
        given. The native mirror of the web
        `Apps::Ideas::CommentsController#create`, sharing
        `Platform::Commentable#post_comment` so both surfaces store a comment —
        and resolve its mentions — identically.

        **Mentions** — send the platform-standard `@[Name](mention:id)` tokens
        inline in `body` (the same markup the web and feeds composers emit). The
        server resolves them to `mentioned_user_ids` and notifies each mentioned
        user. The raw tokens are preserved in the returned `body` so a client can
        linkify them.

        **No attachments** — an idea comment is text + mentions only, matching the
        web composer (which posts a bare `comment[body]` with no file field). An
        idea comment is a plain `Platform::Comment`, which declares no attachment
        association, so a stray `attachments` param is ignored rather than stored.

        **Threading is ONE level.** `parent_comment_id` must name a TOP-LEVEL
        comment of THIS idea; replying to a reply returns `422`
        `reply_depth_exceeded`, and a parent from another idea's thread returns
        `422` `parent_not_found`.

        **Notifications** replicate the web fan-out: the idea's author is told
        about the activity, a reply ALSO notifies the author of the comment being
        replied to, and @mentions ping everyone tagged EXCEPT those recipients and
        the commenter — so one post never sends the same person both a reply and a
        mention. Delivery is best-effort and never fails an accepted comment.

        **At most 25 @mentioned people are notified per comment**, and delivery is
        queued rather than done inside this request (so a 201 means "accepted", not
        "delivered"). The cap is on the NOTIFICATION only: the comment always saves
        with its full `mentioned_user_ids`, every mention still renders and still
        counts for the recipient's Mentions filter. `mentioned_user_ids` is returned
        in full, so a client can compare its length with 25 to know the cap bit. A
        comment naming more than two dozen people individually is a broadcast — use
        Broadcast or a campaign for that audience.

        Returns the created comment in the same shape the thread listing uses,
        plus `total_comments` (comments + replies) so a client can patch the count
        it is already showing without re-fetching.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  description: Comment text; may contain `@[Name](mention:id)` mention
                    tokens. Blank/whitespace-only is rejected.
                  example: Great idea @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
                  description: Top-level comment id to reply to. Omit (or send blank)
                    for a new top-level comment.
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  example: Great idea @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                - total_comments
                properties:
                  comment:
                    "$ref": "#/components/schemas/IdeaComment"
                  total_comments:
                    type: integer
                    description: The idea's discussion volume after the post (comments
                      + replies) — the same number the feed card's `comments_count`
                      shows.
                    example: 7
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id exists in the caller's business (error
            code `not_found`).
        '422':
          description: 'The body was blank (`body_required`), or `parent_comment_id`
            was invalid: `parent_not_found` (not a comment on this idea) or `reply_depth_exceeded`
            (it is itself a reply). Also `content_blocked` when the workspace''s content-moderation
            policy refuses the submitted text; the message is the policy''s own and
            is safe to show the author verbatim.'
  "/ideas/{idea_id}/comments/{id}":
    parameters:
    - name: idea_id
      in: path
      required: true
      description: The idea the comment belongs to.
      schema:
        type: integer
        example: 91
    - name: id
      in: path
      required: true
      description: The comment (or reply) to edit or delete.
      schema:
        type: integer
        example: 5512
    patch:
      tags:
      - Ideas
      summary: Edit a comment or reply on an idea
      description: |
        Edits ONE comment — or one reply — on an idea: its body and its @mentions.

        **AUTHOR ONLY.** Only the comment's own author may edit it, with **no time
        window**. This is deliberately *narrower* than the `DELETE` on this same
        path, which also admits an Ideas admin: deleting is a moderation action the
        web grants admins, whereas editing would let an admin rewrite words that
        stay attributed to their original author. An Ideas admin who is not the
        author gets `403 forbidden` and the comment is untouched.

        Note the web Ideas thread has **no edit affordance at all**
        (`Apps::Ideas::CommentsController` implements only create + destroy), so
        this endpoint follows the platform's comment-edit precedent — WikiComment
        and News Feed both restrict editing to the author — minus their expiry
        window, matching how Ideas already declines to time-limit its deletes.

        The `can_edit` flag on every comment and reply returned by
        `GET /ideas/{idea_id}/comments` is the SAME decision, so a client can render
        the Edit affordance exactly where this call will succeed.

        **@mentions are re-derived from the new body.** Send the platform mention
        token `@[Name](mention:id)` inline, exactly as on create; the server
        re-parses the edited body and rewrites `mentioned_user_ids`, so a mention
        you add starts counting and one you delete stops. A token naming a user
        outside this business is dropped rather than stored. Only the users this
        edit NEWLY mentions are notified — fixing a typo never re-pings the thread —
        and **at most 25 of them**, queued rather than delivered inside this request
        (the same cap and the same queueing as `POST .../comments`; it bounds the
        notification only, never what is stored). Omit `body` entirely to leave the
        text (and therefore its mentions) untouched.

        **No attachments** — an idea comment is text + mentions only on every
        surface (matching the web composer, which has no file field), so there are
        no files to add or remove. A stray `attachments` param is ignored.

        A successful edit stamps `edited_at`, which the comment payload returns so a
        client can show an "edited" marker.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: The new text (leading/trailing whitespace trimmed,
                    like create). Mentions are re-derived from it. OMIT to leave the
                    body and its mentions unchanged; sending it blank is rejected
                    `422 body_required` rather than silently emptying the comment.
                  example: Revised — cc @[Ada Adminson](mention:42)
      responses:
        '200':
          description: Comment updated.
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/IdeaComment"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR the caller is not the comment's author (error code `forbidden`) — an
            Ideas admin who did not write it is refused here too. The comment is left
            untouched.
        '404':
          description: No such comment in this idea's thread within the caller's business
            (error code `not_found`). Covers an unknown id, a comment belonging to
            a DIFFERENT idea, a comment in another tenant, and an already-deleted
            comment — all report `404`, never `403`.
        '422':
          description: "`body_required` — `body` was sent but blank; `invalid` — the
            resulting comment failed validation; `edit_failed` — the write could not
            be completed. In every case the comment is left exactly as it was. Also
            `content_blocked` when the workspace's content-moderation policy refuses
            the submitted text; the message is the policy's own and is safe to show
            the author verbatim."
    delete:
      tags:
      - Ideas
      summary: Delete a comment or reply on an idea
      description: |
        Deletes ONE comment — or one reply — from an idea's discussion. The native
        mirror of the web `Apps::Ideas::CommentsController#destroy` (the **Delete**
        affordance on each comment in the thread).

        **AUTHOR OR IDEAS ADMIN.** The comment's own author may delete it, and so may
        an Ideas admin — a business admin-or-above, or a member holding the Ideas
        app-admin role. This is the web gate verbatim, and there is **no time window**
        (unlike the Wikis comment API's 5-minute edit/delete window). Anyone else gets
        `403 forbidden` and the comment is left untouched.

        Note this gate is *wider* than the sibling `DELETE /ideas/{id}`, which is
        author-only: removing a comment is a moderation action the web grants admins,
        whereas deleting an idea has no admin affordance at all. Authoring the **idea**
        grants nothing here — the gate reads the comment's author, so the idea's author
        cannot remove other people's comments from their own idea.

        The `can_delete` flag on every comment and reply returned by
        `GET /ideas/{idea_id}/comments` is the SAME decision, so a client can render the
        Delete affordance exactly where this call will succeed.

        **A soft delete.** `Platform::Comment` is soft-deletable: the row is retained
        with `deleted_at` (and `deleted_by_id`) stamped, and every read path filters it
        out — the web thread, this API's thread, and every comment count. It is gone as
        far as any user is concerned, and it is not restorable through the API.

        **Replies go with it.** Deleting a top-level comment also deletes its direct
        replies — the cascade the web's own confirm dialog promises ("Its replies will
        be removed too."). `replies_deleted` reports how many went. Replies that were
        already deleted are not re-stamped and are not counted. Deleting a **reply**
        touches only that reply: its parent and its siblings are untouched, and
        `replies_deleted` is `0` (threading is one level deep, so a reply has none).

        **Not idempotent.** A second call returns `404 not_found`, because the comment
        is genuinely gone from every scope the lookup can see. Clients should treat
        `404` on a retry as success.

        `total_comments` is the idea's discussion volume (comments + replies) **after**
        the delete, in the same key `GET /ideas/{idea_id}/comments` uses, so a client can
        patch the count it is already displaying without re-fetching the thread.

        Delete-level (owner decision 2026-10-06, #6720 / #6721): requires the
        `destructive:ideas` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Comment deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - comment_id
                - parent_comment_id
                - deleted
                - replies_deleted
                - total_comments
                - message
                properties:
                  idea_id:
                    type: integer
                    description: The idea the comment belonged to.
                    example: 91
                  comment_id:
                    type: integer
                    description: The id of the comment that was deleted.
                    example: 5512
                  parent_comment_id:
                    type: integer
                    nullable: true
                    description: The deleted comment's parent — `null` when a top-level
                      comment was deleted, the parent's id when a reply was.
                    example:
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  replies_deleted:
                    type: integer
                    description: Direct replies deleted along with the comment (already-deleted
                      replies are not counted). Always 0 when the deleted record was
                      itself a reply.
                    example: 2
                  total_comments:
                    type: integer
                    description: The idea's remaining discussion volume — comments
                      + replies, the same number `GET /ideas/{idea_id}/comments` reports
                      and the feed card's `comments_count` shows.
                    example: 7
                  message:
                    type: string
                    description: "`Comment deleted.` for a top-level comment, `Reply
                      deleted.` for a reply."
                    example: Comment deleted.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR the caller is neither the comment's author nor an Ideas admin (error
            code `forbidden`). The comment is left untouched.
        '404':
          description: No such comment in this idea's thread within the caller's business
            (error code `not_found`). Covers an unknown id, a comment that belongs
            to a DIFFERENT idea, a comment in another tenant, and a comment that is
            already deleted — all report `404`, never `403`, so the response can't
            confirm that the id exists somewhere else.
        '422':
          description: The delete could not be completed (error code `delete_failed`);
            the comment and its replies survive.
  "/ideas/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The idea's id.
      schema:
        type: integer
        example: 91
    get:
      tags:
      - Ideas
      summary: Single idea detail
      description: |
        The full detail for ONE idea — the native mirror of the web detail page
        (`Apps::Ideas::IdeasController#show`). All loading runs through the shared
        `::Ideas::IdeaDetailQuery`, so the web and the API resolve the reviewer
        panel, the recent-voter set, the lifecycle and the comment count
        identically, in a fixed number of queries (no N+1).

        **Lifecycle** — every lifecycle stage of the workspace, in pipeline
        (`position`) order, each marked relative to where this idea sits:
        `done` (before the current stage), `current` (this idea's stage), or
        `upcoming` (after it). This is the strip the web renders.

        **Reviewer panel** — `group_name` is the panel that scores this idea (the
        idea's campaign group when it has one, else the workspace default — the
        web's "This idea is scored by …"). `reviewers` carries up to **3** members
        and is **OMITTED** entirely when the workspace turns "Show reviewer names"
        off; `reviewers_count` (the whole panel size) is always present so a client
        can still render "Review Panel (7)" exactly like the web.

        **Conditional fields**
        * `voting_closes_on` — present ONLY when the workspace enabled the
          voting-close-date feature AND this idea has a date set (key omitted
          otherwise, never null).
        * `rice_score` / `rice_score_band` — present only while RICE scoring is on
          for the workspace. The band is the colour the web paints the score.
        * `rice_inputs` — the four values behind the score, for the scorer to
          pre-fill; present only while scoring is on AND the caller is a reviewer.
        * `attachments` — `[]` when the workspace turned attachments off.

        **`recent_voters`** — the three most recent upvoters, newest first, each
        with the time they voted.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Idea detail retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - idea
                properties:
                  idea:
                    type: object
                    required:
                    - id
                    - title
                    - description
                    - description_html
                    - stage
                    - creator
                    - created_at
                    - vote_count
                    - has_voted
                    - voting_closed
                    - vote_change_allowed
                    - can_edit
                    - can_delete
                    - group_name
                    - campaign
                    - comments_count
                    - lifecycle
                    - reviewers_count
                    - recent_voters
                    - attachments
                    properties:
                      id:
                        type: integer
                        example: 91
                      title:
                        type: string
                        example: Add SSO for contractors
                      description:
                        type: string
                        nullable: true
                        description: The idea's plain-text body.
                        example: We should let contractors sign in with SSO.
                      description_html:
                        type: string
                        nullable: true
                        description: The rich-text body as stored (safe HTML).
                        example: "<p>We should let contractors sign in with SSO.</p>"
                      stage:
                        type: object
                        description: The idea's current lifecycle stage.
                        required:
                        - id
                        - name
                        - color
                        - category
                        properties:
                          id:
                            type: integer
                            example: 3
                          name:
                            type: string
                            example: Reviewing
                          color:
                            type: string
                            description: The stage's hex colour.
                            example: "#2e63b3"
                          category:
                            type: string
                            enum:
                            - entry
                            - active
                            - implemented
                            - declined
                            example: active
                      creator:
                        type: object
                        required:
                        - id
                        - name
                        - photo
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 49290
                          name:
                            type: string
                            nullable: true
                            description: The author's full name.
                            example: Dana Lee
                          photo:
                            type: string
                            nullable: true
                            description: Absolute avatar URL (a ui-avatars initial
                              tile when the user has no photo).
                            example: https://officechat.workforce.mangoapps.com/system/photos/49290/thumb.png
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-07-31T06:01:58Z'
                      vote_count:
                        type: integer
                        description: Positive votes (up_votes_count counter cache).
                        example: 12
                      has_voted:
                        type: boolean
                        description: Whether the CALLING user has upvoted this idea.
                        example: true
                      voting_closed:
                        type: boolean
                        description: True once the idea's voting close date has passed
                          — but only on a workspace where `close_date_enabled` is
                          true. A workspace that has the voting-close-date feature
                          off never reports a closed idea and never refuses a vote,
                          and `voting_closes_on` is withheld.
                        example: false
                      can_vote:
                        type: boolean
                        description: 'Whether this request may press the upvote at
                          all: false once `voting_closed`, and for an API token without
                          write:ideas, which `POST` / `DELETE /ideas/{idea_id}/vote`
                          ask (a signed-in session always holds it). Taking a vote
                          back also needs `vote_change_allowed`.'
                        example: true
                      vote_change_allowed:
                        type: boolean
                        description: 'Whether the caller may remove their upvote from
                          THIS idea now — the workspace allows removing an upvote
                          AND the idea''s voting is not closed (drives the Remove
                          affordance). False on an idea whose `voting_closed` is true:
                          `DELETE /ideas/{idea_id}/vote` answers `422 voting_closed`
                          there.'
                        example: true
                      can_edit:
                        type: boolean
                        description: Whether the CALLING user may edit this idea.
                          True for the **author only** — the web gates edit on `set_own_idea`
                          (`Idea#mine?`) and there is deliberately **no admin override**
                          (its Manage dropdown renders solely under `mine?`). Drives
                          the Edit affordance.
                        example: false
                      can_delete:
                        type: boolean
                        description: Whether the CALLING user may delete this idea
                          — the same author-only rule as `can_edit`, and exactly what
                          `DELETE /ideas/{id}` enforces. Kept as a separate field
                          because it is a separate affordance.
                        example: false
                      rice_score:
                        type: integer
                        nullable: true
                        description: Cached RICE score (null when the idea has not
                          been scored). The key is OMITTED entirely when RICE scoring
                          is turned off for the workspace.
                        example: 42
                      rice_score_band:
                        type: object
                        description: 'The colour band `rice_score` renders as, so
                          a client paints the score pill the same way the web does
                          without hardcoding our ranges. `tier` is the stable key
                          to switch on: `high` (>= 700), `medium` (>= 300), `low`
                          below that, `none` when the idea is awaiting its first score.
                          Travels with `rice_score` and is OMITTED under the same
                          gate (RICE scoring off for the workspace).'
                        properties:
                          tier:
                            type: string
                            enum:
                            - high
                            - medium
                            - low
                            - none
                            example: high
                          label:
                            type: string
                            description: Human label for the band.
                            example: High
                          color:
                            type: string
                            description: Foreground hex the web uses.
                            example: "#146c43"
                          background:
                            type: string
                            description: Background hex the web uses; `transparent`
                              for the `none` band.
                            example: "#d1f0e0"
                      rice_inputs:
                        type: object
                        description: The four stored values behind `rice_score`, so
                          the reviewer's scorer pre-fills from the columns rather
                          than rebuilding them from the audit trail (an idea scored
                          by sample data or a CSV import has no score entry, and the
                          trail is capped at 50). Each is null when never set. Present
                          only when RICE scoring is on AND `is_reviewer` is true —
                          non-reviewers see only the total, like the web. `confidence`
                          is the stored factor (1.0 / 0.8 / 0.5 / 0.3); null scores
                          as 1.0.
                        properties:
                          reach:
                            type: integer
                            nullable: true
                            description: People per quarter.
                            example: 2600
                          impact:
                            type: number
                            nullable: true
                            description: 'Per person: 3, 2, 1, 0.5 or 0.25.'
                            example: 3.0
                          confidence:
                            type: number
                            nullable: true
                            description: Stored confidence factor.
                            example: 0.5
                          effort:
                            type: number
                            nullable: true
                            description: Person-months.
                            example: 0.5
                      scored_by_author:
                        type: boolean
                        description: Present only when RICE scoring is on AND `is_reviewer`
                          is true. True when the idea's score is the one its AUTHOR
                          set — not the panel's review (the web says "Scored by its
                          author"). A reviewer who is not the author (or the author,
                          once nobody else is left on the panel) confirms it by saving
                          the same values.
                        example: false
                      stage_set_by_author:
                        type: boolean
                        description: Present only when `is_reviewer` is true. True
                          when the idea sits on a decided stage its AUTHOR moved it
                          to; a reviewer who is not the author (or the author alone
                          on the panel) confirms it by "moving" it to that same stage
                          (`PATCH /ideas/{idea_id}/stage`).
                        example: false
                      group_name:
                        type: string
                        nullable: true
                        description: Name of the reviewer panel that scores this idea
                          — the idea's campaign review group when it has one, else
                          the workspace default panel. null when no panel is configured.
                        example: Product Review Panel
                      campaign:
                        type: object
                        nullable: true
                        description: The campaign this idea was submitted to; null
                          when it stands alone.
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            example: 5
                          name:
                            type: string
                            example: Frontline Experience
                      comments_count:
                        type: integer
                        description: Non-deleted comments + replies.
                        example: 4
                      voting_closes_on:
                        type: string
                        format: date
                        description: The idea's voting close date (ISO `YYYY-MM-DD`).
                          Present ONLY when the workspace enabled the voting-close-date
                          feature AND this idea has a date set — the key is OMITTED
                          otherwise.
                        example: '2026-08-15'
                      lifecycle:
                        type: array
                        description: Every workspace lifecycle stage in pipeline order,
                          marked relative to this idea's current stage.
                        items:
                          type: object
                          required:
                          - stage_id
                          - name
                          - color
                          - position
                          - state
                          properties:
                            stage_id:
                              type: integer
                              example: 3
                            name:
                              type: string
                              example: Reviewing
                            color:
                              type: string
                              example: "#2e63b3"
                            position:
                              type: integer
                              description: 0-based pipeline position.
                              example: 1
                            state:
                              type: string
                              enum:
                              - done
                              - current
                              - upcoming
                              description: "`done` before the idea's stage, `current`
                                at it, `upcoming` after it."
                              example: current
                      reviewers:
                        type: array
                        description: Up to THREE members of the idea's reviewer panel.
                          OMITTED entirely when "Show reviewer names" is off for the
                          workspace (`reviewers_count` still reports the full panel
                          size).
                        items:
                          type: object
                          required:
                          - id
                          - name
                          - photo
                          properties:
                            id:
                              type: integer
                              example: 77
                            name:
                              type: string
                              nullable: true
                              example: Rae Reviewer
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL.
                              example: https://officechat.workforce.mangoapps.com/system/photos/77/thumb.png
                      reviewers_count:
                        type: integer
                        description: Total size of the reviewer panel (present even
                          when names are hidden).
                        example: 7
                      is_reviewer:
                        type: boolean
                        description: 'Whether the CALLER is on this idea''s review
                          panel — the panel its campaign sets, else the workspace
                          default. This is the web''s `can_review?` rule verbatim:
                          **panel membership is the only grant**, so an Ideas admin
                          who is not a member gets `false` (the web''s reviewer actions
                          carry no admin bypass either). The person''s answer: it
                          gates `audit_entries` below. Draw the stage mover and the
                          RICE scorer from `can_move_stage` / `can_score`, which also
                          ask the request''s token for write:ideas.'
                        example: true
                      can_move_stage:
                        type: boolean
                        description: "`is_reviewer` AND a credential holding write:ideas
                          — the scope `PATCH …/stage` asks. A move into a declined
                          stage asks destructive:ideas on top (per target stage, not
                          in this flag)."
                        example: true
                      can_score:
                        type: boolean
                        description: "`is_reviewer` AND write:ideas AND scoring on
                          for the tenant — what `PATCH …/score` asks."
                        example: true
                      audit_entries:
                        type: array
                        description: |-
                          The idea's audit trail, newest first, capped at 50 — **present only when `is_reviewer` is true**. The web renders its Audit Log panel behind the same gate: the trail names who moved a stage, what the RICE score was before, and reviewer notes, none of which a submitter is shown.
                          The key is OMITTED for a non-reviewer rather than sent empty: `[]` would read as "this idea has no history", which is a different claim from "not yours to see".
                          Each row carries the structured facts (not a pre-built sentence), so a client composes its own copy. `payload` is passed through verbatim so nothing the web can show is lost — a stage `note`, the per-field RICE `changes`, the `edits` list.
                        items:
                          type: object
                          required:
                          - id
                          - entry_type
                          - actor
                          - created_at
                          - icon
                          - color
                          - payload
                          properties:
                            id:
                              type: integer
                              example: 9013
                            entry_type:
                              type: string
                              description: What happened. `create` = the idea was
                                submitted.
                              enum:
                              - create
                              - stage
                              - score
                              - edit
                              example: stage
                            actor:
                              type: object
                              nullable: true
                              description: Who did it; null for a system entry.
                              properties:
                                id:
                                  type: integer
                                  example: 77
                                name:
                                  type: string
                                  example: Rae Reviewer
                                photo:
                                  type: string
                                  nullable: true
                                  description: Absolute avatar URL.
                            created_at:
                              type: string
                              format: date-time
                              example: '2026-07-31T09:30:28Z'
                            icon:
                              type: string
                              description: Font Awesome glyph for the entry type —
                                the same one the web uses, so both surfaces read identically.
                              example: fa-arrow-right-arrow-left
                            color:
                              type: string
                              description: Accent hex for the entry type (matches
                                the web).
                              example: "#2e63b3"
                            payload:
                              type: object
                              description: 'The raw, type-specific detail. `stage`
                                → `{from, to, note?, category_from?, category_to?}`
                                (stage ids); `score` → `{score_from, score_to, changes:
                                [{field, from, to}]}`; `edit` → `{edits: [...]}`;
                                `create` → `{}`. A `stage` entry with `category_from`
                                / `category_to` (`entry`, `active`, `implemented`
                                or `declined`) records an Ideas admin changing the
                                CATEGORY of the stage the idea stands on, which decides
                                or reopens every idea on it at once: the idea did
                                not move, so `from` == `to`, and it is not a reviewer
                                confirming the stage. A `stage` entry with `from`
                                == `to` and no category keys is a reviewer confirming
                                the decided stage the idea''s author chose.'
                              additionalProperties: true
                              example:
                                from: 1
                                to: 2
                                note: Looks good
                            by_author:
                              type: boolean
                              description: True on a `score` or `stage` entry made
                                by the idea's own AUTHOR — a score or move that is
                                not the review panel's. Never true on a lifecycle-stage
                                reset's park entry nor on an Ideas admin re-categorising
                                the stage (`category_to`), and always false on `create`
                                / `edit` entries.
                              example: false
                            from_stage:
                              type: object
                              nullable: true
                              description: '`stage` entries ONLY (absent on other
                                types): the payload''s `from` id resolved for rendering.
                                Null only when the entry has no `from` (an entry INTO
                                the pipeline). A stage deleted since resolves to a
                                stand-in carrying the stored `id`, `name` "a stage
                                since deleted" (translated like the web) and a grey
                                `color`; a renamed stage resolves to its current name.'
                              properties:
                                id:
                                  type: integer
                                  example: 1
                                name:
                                  type: string
                                  example: New
                                color:
                                  type: string
                                  example: "#6c757d"
                            to_stage:
                              type: object
                              nullable: true
                              description: '`stage` entries ONLY: the payload''s `to`
                                id resolved the same way, including the "a stage since
                                deleted" stand-in for a stage deleted since.'
                              properties:
                                id:
                                  type: integer
                                  example: 2
                                name:
                                  type: string
                                  example: Reviewing
                                color:
                                  type: string
                                  example: "#997404"
                      audit_entries_truncated:
                        type: boolean
                        description: |-
                          Whether `audit_entries` above may be only the NEWEST page of the trail. Present exactly when `audit_entries` is — i.e. only for a reviewer — so a flag never appears without the collection it describes.
                          The other two capped collections on this payload ship their real totals (`reviewers_count`, `vote_count`); the audit trail's true total is not available without an extra query, so it ships this flag instead. It is the same detector the web uses for its "Showing the 50 most recent entries." line, which means it is *possibly* truncated: an idea with exactly 50 entries reports `true`. It over-reports by at most one page and never under-reports, so a client may safely use it to decide whether to caveat the list.
                        example: false
                      recent_voters:
                        type: array
                        description: The three most recent upvoters, newest first.
                        items:
                          type: object
                          required:
                          - id
                          - name
                          - photo
                          - voted_at
                          properties:
                            id:
                              type: integer
                              example: 49290
                            name:
                              type: string
                              nullable: true
                              example: Dana Lee
                            photo:
                              type: string
                              nullable: true
                              description: Absolute avatar URL.
                            voted_at:
                              type: string
                              format: date-time
                              description: When this person upvoted.
                              example: '2026-07-31T06:15:30Z'
                      attachments:
                        type: array
                        description: Files attached to the idea. `[]` when the workspace
                          turned attachments off.
                        items:
                          type: object
                          required:
                          - id
                          - filename
                          - content_type
                          - byte_size
                          - url
                          properties:
                            id:
                              type: integer
                              example: 2637
                            filename:
                              type: string
                              example: proposal.pdf
                            content_type:
                              type: string
                              nullable: true
                              example: application/pdf
                            byte_size:
                              type: integer
                              example: 88877
                            url:
                              type: string
                              description: Absolute download URL (Content-Disposition
                                attachment).
                              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJ.../proposal.pdf?disposition=attachment
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
    patch:
      tags:
      - Ideas
      summary: Edit an idea
      description: |
        Edits an idea — the native mirror of the web Manage ▸ Edit drawer
        (`Apps::Ideas::IdeasController#update`), sharing
        `Ideas::IdeaUpdateService`, `Ideas::AttachmentScreener` and
        `Ideas::DescriptionHtml` so the two surfaces behave identically.

        **PATCH semantics** — every field is optional and a key you do NOT send is left
        unchanged, so editing the title can never blank the description. The web always
        posts its whole form, so sending everything behaves the same.

        **The campaign is never silently detached.** `campaign_id` is applied only when
        you send it; omitting it keeps the current campaign. The web learned this the
        hard way — its campaign select renders only when an open campaign exists, so an
        unconditional assign detached ideas whose campaign had since closed, via a
        field the author could not even see.

        **Adding attachments is gated.** `files[]` / `file_signed_ids[]` are
        accepted only while **"Allow file attachments"** is ON; when OFF they are
        dropped and the edit still applies. Sending new files never replaces an
        existing one. **Removing is explicit:** `remove_file_ids[]` takes attachment
        ids (each `attachments[].id` on the idea) off the idea — the web edit form's
        per-file "Remove" — whatever the toggle says; an id that is not one of this
        idea's attachments is ignored. Attach and remove run **inside the same
        transaction as the save**, so a failure rolls the edit back rather than
        committing a half-applied change, and every attach or remove (a swap included) is recorded in
        `edits` as `Attachments`. Anything dropped is reported per-file in
        `attachment_errors` and repeated in `warnings`.

        **`warnings` names every field this workspace's settings discarded**, on a 200
        that applied only part of the edit: a dropped file, a `voting_closes_on` the
        close-date toggle refused (whether you were setting one or clearing one), a
        `campaign_id` the campaigns toggle refused. Omitted entirely when nothing was
        dropped.

        **Authorization: the AUTHOR only** (`Idea#mine?`), with deliberately no admin
        override — the web's Manage dropdown renders solely under `mine?`, so an admin
        gets `403` too. An id in another tenant returns `404`.

        The edit is **audited with a diff** (`Ideas::AuditLogger.log_edit!`) and the
        recorded `edits` are echoed back: a title's from/to, that the description
        changed (without dumping the body), a moved close date, and a campaign move by
        name.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        description: "`application/json`, or `multipart/form-data` when sending `files[]`."
        content:
          application/json:
            schema:
              type: object
              description: Send only the fields you want to change.
              properties:
                title:
                  type: string
                  maxLength: 200
                  description: Replaces the title. Whitespace is stripped; blank is
                    rejected (422).
                  example: Dark mode for the mobile app (revised)
                description:
                  type: string
                  description: Replaces the body; `description_html` is re-derived
                    (escaped, then formatted).
                campaign_id:
                  type: integer
                  nullable: true
                  description: MOVES the idea to this campaign. Honoured only while
                    Campaigns is on. Omitting it never detaches the current campaign.
                    Must belong to this business and be open, else `422`.
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                  description: Sets the voting close date; honoured only while that
                    feature is on. Send it EMPTY (`""` or `null`) to CLEAR the date
                    — that is how the web clears it.
                remove_file_ids:
                  type: array
                  description: Attachment ids (`attachments[].id`) to remove from
                    this idea. Ids that are not this idea's attachments are ignored.
                  items:
                    type: integer
          multipart/form-data:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: string
                campaign_id:
                  type: integer
                  nullable: true
                voting_closes_on:
                  type: string
                  format: date
                  nullable: true
                files[]:
                  type: array
                  description: Files to ADD. Accepted only while "Allow file attachments"
                    is ON; otherwise dropped (the edit still applies). Screened against
                    a size cap and a sniffed content-type allowlist.
                  items:
                    type: string
                    format: binary
                file_signed_ids[]:
                  type: array
                  description: ActiveStorage signed ids, for clients that direct-upload
                    first. Re-screened server-side; an already-attached blob is rejected.
                  items:
                    type: string
                remove_file_ids[]:
                  type: array
                  description: Attachment ids (`attachments[].id` from GET /api/v1/ideas/{id})
                    to remove from this idea. Ids that are not this idea's attachments
                    are ignored. Honoured whatever the attachments toggle says.
                  items:
                    type: integer
      responses:
        '200':
          description: Idea updated
          content:
            application/json:
              schema:
                type: object
                required:
                - idea
                - edits
                - attachment_errors
                properties:
                  idea:
                    type: object
                    description: The updated idea, in the SAME shape `GET /api/v1/ideas/{id}`
                      returns — one query object and one serializer back both.
                  edits:
                    type: array
                    description: The audit diff that was recorded. Empty when nothing
                      actually changed. `Description` carries no from/to — the trail
                      is a summary, not a copy of the body.
                    items:
                      type: object
                      properties:
                        field:
                          type: string
                          example: Title
                        from:
                          type: string
                          nullable: true
                          example: Dark mode
                        to:
                          type: string
                          nullable: true
                          example: Dark mode (revised)
                    example:
                    - field: Title
                      from: Dark mode
                      to: Dark mode (revised)
                    - field: Description
                  attachment_errors:
                    type: array
                    description: One reason per file that was NOT attached — wrong
                      type, over the size cap, or the workspace's "Allow file attachments"
                      setting being off. Always present; empty when everything attached.
                      Never fatal.
                    items:
                      type: string
                  warnings:
                    type: array
                    description: One sentence per field this workspace's settings
                      discarded (a dropped file, a refused `voting_closes_on`, a refused
                      `campaign_id`). OMITTED when nothing was dropped, so the key's
                      presence is the signal that the 200 is a partial write.
                    items:
                      type: string
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller is not the idea's author (error code `forbidden`
            — the message says "edit"), or the Ideas app is not accessible (`access_denied`).
        '404':
          description: No idea with that id in this business (error code `not_found`).
        '422':
          description: Validation failed (error code `invalid`) — a blank title, a
            title over 200 characters, or a campaign that is closed or belongs to
            another business. Also `content_blocked` when the workspace's content-moderation
            policy refuses the submitted text; the message is the policy's own and
            is safe to show the author verbatim.
    delete:
      tags:
      - Ideas
      summary: Delete an idea
      description: |
        Permanently deletes an idea — the native mirror of the web
        `Apps::Ideas::IdeasController#destroy` (the Manage ▸ **Delete idea** action
        on the idea detail page).

        **AUTHOR-ONLY.** Only the user who submitted the idea may delete it. This is
        the web rule verbatim (`Idea#mine?`): the web renders its Manage dropdown
        solely for the author, so **there is no admin override** — a workspace admin
        who did not write the idea gets `403 forbidden`, same as any other member.
        Note this is stricter than reading: `GET /ideas/{id}` is open to every member
        of the business.

        **What the delete removes** (all inside ONE transaction — a failure rolls
        the whole thing back and the idea survives intact):
        * the idea row itself, and its ActiveStorage attachments (blobs purged),
        * its votes — hard-deleted,
        * its audit-trail entries — hard-deleted,
        * its comments and threaded replies — **soft-deleted** (`deleted_at`
          stamped). The rows are retained, exactly as the web's cascade leaves them,
          because `Platform::Comment` is soft-deletable. They disappear from every
          read either way.

        The idea's **campaign is not affected** — only the idea leaves it.

        **Not idempotent.** A second call returns `404 not_found`, because the idea
        is genuinely gone. Clients should treat `404` on a retry as success.

        `votes_deleted` and `comments_deleted` report what the cascade actually
        touched, so a client can confirm the destructive scope it warned the user
        about ("its votes and comments will be permanently removed").

        Delete-level (owner decision 2026-10-06, #6720 / #6721): requires the
        `destructive:ideas` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Idea deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - idea_id
                - deleted
                - votes_deleted
                - comments_deleted
                - message
                properties:
                  idea_id:
                    type: integer
                    description: The id of the idea that was deleted.
                    example: 91
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  votes_deleted:
                    type: integer
                    description: Votes removed with the idea.
                    example: 12
                  comments_deleted:
                    type: integer
                    description: Comments and replies trashed with the idea.
                    example: 4
                  message:
                    type: string
                    example: Idea deleted.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Ideas app is not accessible to the caller (error code `access_denied`),
            OR the caller is not the idea's author (error code `forbidden`). The idea
            is left untouched.
        '404':
          description: No idea with that id in the caller's business (error code `not_found`).
            An idea belonging to another tenant reports `404`, never `403`, so the
            response can't confirm that the id exists elsewhere.
        '422':
          description: The delete could not be completed and was rolled back (error
            code `delete_failed`); the idea and everything attached to it survive.
  "/training/courses/{course_id}/refresh":
    get:
      tags:
      - Training
      summary: Optional recall (GET)
      description: Learner-owned. GET is read-only and hides answer keys until submission.
        POST returns practice state. Does not create formal quiz attempts. Requires
        read:training plus Training app access and the per-record role. Errors use
        the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    post:
      tags:
      - Training
      summary: Optional recall (POST)
      description: Learner-owned. GET is read-only and hides answer keys until submission.
        POST returns practice state. Does not create formal quiz attempts. Requires
        write:training plus Training app access and the per-record role. Errors use
        the standard error envelope. Stopping also requires destructive:training.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                decision:
                  type: string
                  enum:
                  - subscribe
                  - start
                  - answer
                  - pause
                  - resume
                  - snooze
                  - stop
                session_id:
                  type: integer
                answers:
                  type: object
                  additionalProperties:
                    type: string
  "/training/courses/{course_id}/refresh_sets":
    get:
      tags:
      - Training
      summary: Review refresh question copies (GET)
      description: Training admin only. GET accepts lesson_id to return the exact
        review source. POST explicitly approves selected keys and requires the matching
        digest. Requires read:training plus Training app access and the per-record
        role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: lesson_id
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Paginated Training collection with the standard items/meta
            envelope, compatibility alias, and unread_notification_count.
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                - total_count
                - sets
                - total
                - can_approve
                - unread_notification_count
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  sets:
                    type: array
                    description: Compatibility alias of items.
                    items:
                      type: object
                      additionalProperties: true
                  total_count:
                    type: integer
                  total:
                    type: integer
                    description: Compatibility alias of total_count.
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - has_next_page
                    - has_prev_page
                    properties:
                      total_count:
                        type: integer
                        minimum: 0
                      total_pages:
                        type: integer
                        minimum: 0
                      current_page:
                        type: integer
                        minimum: 1
                      per_page:
                        type: integer
                        minimum: 1
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                  can_approve:
                    type: boolean
                  unread_notification_count:
                    type: integer
                    nullable: true
                  source:
                    type: object
                    nullable: true
                    additionalProperties: true
                additionalProperties: true
              example:
                items: []
                sets: []
                total_count: 0
                total: 0
                meta:
                  total_count: 0
                  total_pages: 0
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                can_approve: false
                unread_notification_count:
                source:
          headers:
            X-Total-Count:
              description: Total accessible rows
              schema:
                type: integer
            X-Total-Pages:
              description: Number of pages
              schema:
                type: integer
            X-Current-Page:
              description: Current page
              schema:
                type: integer
            X-Per-Page:
              description: Page size (20)
              schema:
                type: integer
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    post:
      tags:
      - Training
      summary: Review refresh question copies (POST)
      description: Training admin only. GET accepts lesson_id to return the exact
        review source. POST explicitly approves selected keys and requires the matching
        digest. Requires write:training plus Training app access and the per-record
        role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                lesson_id:
                  type: integer
                digest:
                  type: string
                keys:
                  type: array
                  items:
                    type: string
                reviewed:
                  type: boolean
  "/training/courses/{course_id}/refresh_sets/{id}":
    patch:
      tags:
      - Training
      summary: Withdraw refresh approval (PATCH)
      description: Training admin only. Stops future sessions; existing results remain.
        Requires destructive:training plus Training app access and the per-record
        role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
  "/training/courses/{course_id}/language_variants":
    get:
      tags:
      - Training
      summary: Link language editions (GET)
      description: Training admin only. Creates an unapproved link; grants no credit.
        Requires read:training plus Training app access and the per-record role. Errors
        use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Paginated Training collection with the standard items/meta
            envelope, compatibility alias, and unread_notification_count.
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                - total_count
                - variants
                - total
                - can_link
                - unread_notification_count
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  variants:
                    type: array
                    description: Compatibility alias of items.
                    items:
                      type: object
                      additionalProperties: true
                  total_count:
                    type: integer
                  total:
                    type: integer
                    description: Compatibility alias of total_count.
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - has_next_page
                    - has_prev_page
                    properties:
                      total_count:
                        type: integer
                        minimum: 0
                      total_pages:
                        type: integer
                        minimum: 0
                      current_page:
                        type: integer
                        minimum: 1
                      per_page:
                        type: integer
                        minimum: 1
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                  can_link:
                    type: boolean
                  unread_notification_count:
                    type: integer
                    nullable: true
                additionalProperties: true
              example:
                items: []
                variants: []
                total_count: 0
                total: 0
                meta:
                  total_count: 0
                  total_pages: 0
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                can_link: false
                unread_notification_count:
          headers:
            X-Total-Count:
              description: Total accessible rows
              schema:
                type: integer
            X-Total-Pages:
              description: Number of pages
              schema:
                type: integer
            X-Current-Page:
              description: Current page
              schema:
                type: integer
            X-Per-Page:
              description: Page size (20)
              schema:
                type: integer
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    post:
      tags:
      - Training
      summary: Link language editions (POST)
      description: Training admin only. Creates an unapproved link; grants no credit.
        Requires write:training plus Training app access and the per-record role.
        Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                variant_course_id:
                  type: integer
                locale:
                  type: string
  "/training/courses/{course_id}/language_variants/{id}":
    get:
      tags:
      - Training
      summary: Review exact language equivalence (GET)
      description: Training admin only. Independent reviewer must inspect all coverage
        and match both digests. Withdraw ends future credit and releases the pending
        choices it held (each learner told in the app); a link nobody approved can
        be removed. Requires read:training plus Training app access and the per-record
        role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    patch:
      tags:
      - Training
      summary: Review exact language equivalence (PATCH)
      description: Training admin only. Independent reviewer must inspect all coverage
        and match both digests. Withdraw ends future credit and releases the pending
        choices it held (each learner told in the app); a link nobody approved can
        be removed. Requires write:training plus Training app access and the per-record
        role. Errors use the standard error envelope. Withdrawal also requires destructive:training.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                decision:
                  type: string
                  enum:
                  - approve
                  - withdraw
                  - remove
                  - release
                  - release_pending
                  description: approve (write:training) — an independent admin approves
                    the exact editions. withdraw (destructive:training) — ends future
                    credit and releases every pending choice in the background; the
                    response's `releasing` is how many. remove (destructive:training)
                    — deletes a link nobody approved (`can_remove`). release (destructive:training,
                    with `enrollment_id` or `choice_id`) — ends one learner's unfulfilled
                    choice. release_pending (destructive:training) — releases what
                    a withdrawn approval still holds (`pending_choices`, `can_release_pending`).
                choice_id:
                  type: integer
                  description: 'With decision=release: the language choice to end.'
                enrollment_id:
                  type: integer
                  description: 'With decision=release, instead of choice_id: the learner''s
                    assignment, or the language course enrollment chosen for it, whose
                    choice of this edition pair to end.'
                coverage:
                  type: array
                  items:
                    type: string
                source_digest:
                  type: string
                variant_digest:
                  type: string
  "/training/enrollments/{enrollment_id}/language_choice":
    get:
      tags:
      - Training
      summary: Own language choice (GET)
      description: Learner-owned current assignment. Choose or remove only before
        work. GET returns approved options, chosen actual enrollment, fulfilled_at
        and can_choose. DELETE withdraws the choice with destructive:training; an
        unstarted, unassigned language-course enrollment the choice itself created
        is cancelled, and learning history is kept. Requires read:training plus Training
        app access and the per-record role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: enrollment_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    post:
      tags:
      - Training
      summary: Own language choice (POST)
      description: Learner-owned current assignment. Choose or remove only before
        work. GET returns approved options, chosen actual enrollment, fulfilled_at
        and can_choose. DELETE withdraws the choice with destructive:training; an
        unstarted, unassigned language-course enrollment the choice itself created
        is cancelled, and learning history is kept. Requires write:training plus Training
        app access and the per-record role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: enrollment_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                variant_id:
                  type: integer
    delete:
      tags:
      - Training
      summary: Own language choice (DELETE)
      description: Learner-owned current assignment. Choose or remove only before
        work. GET returns approved options, chosen actual enrollment, fulfilled_at
        and can_choose. DELETE withdraws the choice with destructive:training; an
        unstarted, unassigned language-course enrollment the choice itself created
        is cancelled, and learning history is kept. Requires destructive:training
        plus Training app access and the per-record role. Errors use the standard
        error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: enrollment_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                variant_id:
                  type: integer
  "/training/contributions":
    get:
      tags:
      - Training
      summary: Private learning contributions (GET)
      description: GET supports review=1 for the authorized pending queue and review=history
        for verified, rejected or withdrawn entries in the same reviewer scope and
        course_id for eligible source choices. Otherwise lists own entries. Twenty
        per page. Submission requires own course enrollment; public consent defaults
        false. Requires read:training plus Training app access and the per-record
        role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: review
        in: query
        description: Omit to list own contributions.
        schema:
          type: string
          enum:
          - '1'
          - history
      - name: course_id
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Paginated Training collection with the standard items/meta
            envelope, compatibility alias, and unread_notification_count.
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                - total_count
                - contributions
                - total
                - can_submit
                - unread_notification_count
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  contributions:
                    type: array
                    description: Compatibility alias of items.
                    items:
                      type: object
                      additionalProperties: true
                  total_count:
                    type: integer
                  total:
                    type: integer
                    description: Compatibility alias of total_count.
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - has_next_page
                    - has_prev_page
                    properties:
                      total_count:
                        type: integer
                        minimum: 0
                      total_pages:
                        type: integer
                        minimum: 0
                      current_page:
                        type: integer
                        minimum: 1
                      per_page:
                        type: integer
                        minimum: 1
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                  can_submit:
                    type: boolean
                  unread_notification_count:
                    type: integer
                    nullable: true
                  page:
                    type: integer
                    description: Compatibility alias of meta.current_page.
                  sources:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                additionalProperties: true
              example:
                items: []
                contributions: []
                total_count: 0
                total: 0
                meta:
                  total_count: 0
                  total_pages: 0
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                can_submit: false
                unread_notification_count:
                page: 1
                sources: []
          headers:
            X-Total-Count:
              description: Total accessible rows
              schema:
                type: integer
            X-Total-Pages:
              description: Number of pages
              schema:
                type: integer
            X-Current-Page:
              description: Current page
              schema:
                type: integer
            X-Per-Page:
              description: Page size (20)
              schema:
                type: integer
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    post:
      tags:
      - Training
      summary: Private learning contributions (POST)
      description: GET supports review=1 for the authorized pending queue and course_id
        for eligible source choices. Otherwise lists own entries. Twenty per page.
        Submission requires own course enrollment; public consent defaults false.
        Requires write:training plus Training app access and the per-record role.
        Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters: []
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                course_id:
                  type: integer
                kind:
                  type: string
                  enum:
                  - application
                  - helpful_answer
                  - published_correction
                source_id:
                  type: integer
                body:
                  type: string
                  maxLength: 2000
                public_consent:
                  type: boolean
  "/training/contributions/{id}":
    get:
      tags:
      - Training
      summary: Review a learning contribution (GET)
      description: An independent manager/course reviewer verifies evidence. Application
        requires observed=true. Recognition additionally requires write:recognition
        and Recognitions permission; explicit reviewed message and program rules apply.
        Zero value, one award per contribution. Withdraw/revoke also revokes its award.
        Requires read:training plus Training app access and the per-record role. Errors
        use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    patch:
      tags:
      - Training
      summary: Review a learning contribution (PATCH)
      description: An independent manager/course reviewer verifies evidence. Application
        requires observed=true. Recognition additionally requires write:recognition
        and Recognitions permission; explicit reviewed message and program rules apply.
        Zero value, one award per contribution. Withdraw/revoke also revokes its award.
        Requires write:training plus Training app access and the per-record role.
        Errors use the standard error envelope. Withdraw, reject and revoke also require
        destructive:training; withdrawing or revoking an awarded contribution additionally
        requires destructive:recognition. Own-recognition tokens retain only direct-report
        recipient access.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                decision:
                  type: string
                  enum:
                  - resubmit
                  - withdraw
                  - verify
                  - needs_detail
                  - reject
                  - revoke
                  - recognize
                body:
                  type: string
                note:
                  type: string
                observed:
                  type: boolean
                message:
                  type: string
                category_id:
                  type: integer
  "/training/courses/{course_id}/versions/{version_id}/briefing":
    get:
      tags:
      - Training
      summary: Review edition briefing (GET)
      description: Course content manager only. GET returns source/target digests
        and the prior reviewed receipt. PATCH requires human review and exact matching
        editions. Learner course detail returns version_brief including the explanation
        only while fresh. Requires read:training plus Training app access and the
        per-record role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: version_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
    patch:
      tags:
      - Training
      summary: Review edition briefing (PATCH)
      description: Course content manager only. GET returns source/target digests
        and the prior reviewed receipt. PATCH requires human review and exact matching
        editions. Learner course detail returns version_brief including the explanation
        only while fresh. Requires write:training plus Training app access and the
        per-record role. Errors use the standard error envelope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: version_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Training payload plus unread_notification_count
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          description: Missing scope, app access or role
        '404':
          description: Record outside the permitted scope
        '422':
          description: Validation or stale review refused; nothing changed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                summary:
                  type: string
                  minLength: 10
                  maxLength: 5000
                reviewed:
                  type: boolean
                source_digest:
                  type: string
                target_digest:
                  type: string
  "/training/return_reminders":
    get:
      tags:
      - Training
      summary: Read your optional learning reminder schedule
      description: Requires read:training. Self only. No schedule is enabled by default.
        Return and recall practice share a one-per-local-day and two-per-rolling-seven-days
        cap. can_change reflects write:training.
      parameters:
      - name: enrollment_id
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Personal schedule, timezone, daily_limit, weekly_limit, optional
            snoozed_until, can_snooze and scope-aware can_change, with unread_notification_count.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingReturnReminderResponse"
        '403':
          description: Missing scope or Training access.
    patch:
      tags:
      - Training
      summary: Set or pause your optional learning reminder schedule
      description: Requires write:training. Times use the learner's effective timezone.
        Omit or clear delivery_hour to turn optional reminders off. Mandatory deadlines
        and reminders are unchanged. In-app and push channels obey personal notification
        preferences and quiet hours.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                delivery_hour:
                  type: integer
                  nullable: true
                  enum:
                  - 9
                  - 12
                  - 17
                  -
      responses:
        '200':
          description: Saved reminders and can_change, with unread_notification_count.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingReturnReminderResponse"
        '422':
          description: Standard structured validation error.
    post:
      tags:
      - Training
      summary: Remind me later or undo a course snooze
      description: Requires write:training. Only your current, available, active course
        attempt is eligible. A snooze sets your preferred hour and suppresses this
        course until the chosen date; actual delivery remains subject to shared caps,
        deadlines, access, activity and notification preferences. Undo clears the
        snooze, not the preferred schedule. No progress or deadline change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - enrollment_id
              - days
              properties:
                enrollment_id:
                  type: integer
                days:
                  type: string
                  enum:
                  - '1'
                  - '3'
                  - '7'
                  - undo
                delivery_hour:
                  type: integer
                  enum:
                  - 9
                  - 12
                  - 17
      responses:
        '200':
          description: Saved reminders with snoozed_until and can_change, with unread_notification_count.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingReturnReminderResponse"
        '403':
          description: Missing write scope or Training access.
        '422':
          description: Invalid action or unavailable enrollment; standard structured
            error.
  "/training/checklists":
    get:
      tags:
      - Training
      summary: Checklists to sign
      description: |
        The learners whose sign-off checklist items the caller signs (a course's
        Evaluators and Instructors, a Training admin, a class's instructor, or a
        manager where the checklist lets managers sign), soonest due first — the
        web and phone "Checklists to sign" list. `status=waiting` (default) is
        the learners with something still to sign; `all` adds the finished ones.
        `courses` lists what `course_id` can narrow to. Someone who signs nothing
        is refused (403) with the sentence to show.

        **Token scope:** `read:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - waiting
          - all
      - name: course_id
        in: query
        required: false
        schema:
          type: integer
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: A learner's name.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: One page of learners.
          content:
            application/json:
              schema:
                type: object
                properties:
                  active_status:
                    type: string
                    enum:
                    - waiting
                    - all
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        enrollment_id:
                          type: integer
                        lesson:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            title:
                              type: string
                        course:
                          type: object
                          properties:
                            id:
                              type: integer
                            title:
                              type: string
                              nullable: true
                        learner:
                          "$ref": "#/components/schemas/TrainingChecklistPerson"
                        due_date:
                          type: string
                          format: date
                          nullable: true
                        items_total:
                          type: integer
                        items_passing:
                          type: integer
                          description: Items holding a current Met or N/A.
                  courses:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                  meta:
                    type: object
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — the caller signs no checklist; or Training is
            not open to them, or the token lacks `read:training`."
  "/training/checklists/{lesson_id}/learners/{enrollment_id}":
    get:
      tags:
      - Training
      summary: One learner's sign-off checklist
      description: |
        The checklist item by item, with each item's current sign-off, the
        results it takes (N/A only where the item allows it), whether a comment
        is required, and whether the caller may sign or withdraw. `sign_refusal`
        says why nothing can be signed now (a completed or archived course, a
        learner who has left, or the caller not a signer). Readable by whoever
        may sign it or reopen it; the learner reads their own in the lesson.

        **Token scope:** `read:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
      - name: enrollment_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The checklist.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingChecklistAttempt"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — not a signer or reopener of this learner's checklist
            (the learner themself included); the message says why."
        '404':
          description: "`not_found` — no such checklist lesson, or no such learner
            on its course."
  "/training/checklists/{lesson_id}/learners/{enrollment_id}/items/{item_key}/sign":
    post:
      tags:
      - Training
      summary: Sign one checklist item
      description: |
        Records a sign-off for one item, as the web sign-off page does: the
        same rule for who may sign (never the learner), the same checks on the
        result, the validation method and the comment. Answers the sign-off and
        the checklist as it now stands.

        **Token scope:** `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
      - name: enrollment_id
        in: path
        required: true
        schema:
          type: integer
      - name: item_key
        in: path
        required: true
        schema:
          type: string
          pattern: "^[a-z0-9]{4,16}$"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - result
              - validation_method
              properties:
                result:
                  type: string
                  enum:
                  - met
                  - unmet
                  - not_applicable
                  description: One of the item's own `results`.
                validation_method:
                  type: string
                  description: One of the checklist's `validation_methods`.
                comment:
                  type: string
                  description: Required where the item's `comment_required` is true.
                signed_at:
                  type: string
                  format: date-time
                  description: When the item was signed on the device, for a sign-off
                    made offline and sent later. Absent means now. Recorded as the
                    sign-off's `signed_at`; who may sign is still checked when it
                    arrives. Refused (422) when more than 5 minutes ahead of the server's
                    clock or more than 7 days old; a time up to 5 minutes ahead is
                    recorded as now. Refused (409) when the item was signed, withdrawn
                    or reopened after it.
                  example: '2026-11-02T09:41:00Z'
      responses:
        '200':
          description: Signed.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingChecklistAttempt"
                - type: object
                  properties:
                    signoff:
                      "$ref": "#/components/schemas/TrainingChecklistSignoff"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — not a signer; the record can no longer take
            a sign-off (a completed course, an archived course, a learner who has
            left); or the token lacks `write:training`. The message says which."
        '404':
          description: "`not_found` — no such checklist, learner or item."
        '409':
          description: "`conflict` — someone else signed this item at the same moment,
            or (with `signed_at`) the item was signed, withdrawn or reopened after
            the device's time; reload the checklist."
        '422':
          description: "`invalid` — a result the item does not take, an unknown validation
            method, a missing required comment, or a `signed_at` that is unreadable,
            in the future, or more than 7 days old."
  "/training/checklists/{lesson_id}/learners/{enrollment_id}/signoffs/{signoff_id}/withdraw":
    post:
      tags:
      - Training
      summary: Withdraw a sign-off
      description: |
        Takes a current sign-off back out of the learner's record, with a
        reason; it stays in the item's history and the learner is told. The
        person who gave it (while they may still sign), or anyone who may reopen
        the checklist. Reopening a whole checklist stays on the desktop page.

        **Token scope:** `destructive:training` (a final act).
      security:
      - BearerAuth: []
      parameters:
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
      - name: enrollment_id
        in: path
        required: true
        schema:
          type: integer
      - name: signoff_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Withdrawn; the checklist as it now stands.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingChecklistAttempt"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — not allowed to withdraw this sign-off; the record
            is closed (a completed course, an archived course, a learner who has left);
            or the token lacks `destructive:training`."
        '404':
          description: "`not_found` — no such checklist lesson, or no such learner
            on its course."
        '409':
          description: "`conflict` — the sign-off is not a current one on this checklist
            (already changed or withdrawn); reload the checklist."
        '422':
          description: "`invalid` — no reason, or one too long (the message gives
            the limit)."
  "/training/my_training":
    get:
      tags:
      - Training
      summary: My Training home
      description: |
        The "My Training" home header — the native mirror of the top of the
        learner dashboard. Returns the three pieces of chrome above the course
        list (the list itself is the paginated `/training/my_training/enrollments`
        endpoint): the filter-tab counts, the overdue banner, and the "Continue
        where you left off" cards. Strictly scoped to the caller.

        **Sections:**
        * `counts` — LEGACY, may overlap: badges for the All / In Progress /
          Assigned / Completed tabs plus the Overdue banner, unchanged for shipped
          apps. in-progress/assigned/overdue are measured over the active set (an
          assigned, in-progress course counts in both In Progress and Assigned; an
          overdue one in In Progress and Overdue), completed over the completed
          set. `all` is every enrollment except cancelled.
        * `bucket_counts` — the PARTITION the web My Learning chips use
          (Training::LearningStatus): not_started + in_progress + overdue is every
          open enrollment, each counted once (overdue first). Pair it with the
          `bucket` filter on `/training/my_training/enrollments`.
        * `overdue` — `count` plus the most-overdue course/path rows, so the
          client can render the banner ("<title> is <n> days overdue").
        * `continue_learning` — up to 4 recently-accessed active COURSE
          enrollments (newest access first), each a card that additionally
          carries `next_lesson` (the lesson to resume into — the prototype's
          "Next: …" line).

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: minutes
        in: query
        required: false
        schema:
          type: integer
          enum:
          - 3
          - 5
          - 10
        description: Optional time preference; never promises a formal assessment
          will fit.
      responses:
        '200':
          description: My Training home retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - my_training
                properties:
                  my_training:
                    type: object
                    required:
                    - counts
                    - bucket_counts
                    - overdue
                    - continue_learning
                    properties:
                      next_step:
                        type: object
                        nullable: true
                        description: One eligible next activity, or null. Read-only
                          selection does not start an enrollment. Native estimated/measured
                          duration is advisory; assessments and opaque packages never
                          claim to fit a short budget.
                        properties:
                          course_id:
                            type: integer
                          lesson_id:
                            type: integer
                          enrollment_id:
                            type: integer
                          course_version_id:
                            type: integer
                          course_title:
                            type: string
                          lesson_title:
                            type: string
                          url:
                            type: string
                          mobile_url:
                            type: string
                          estimated_seconds:
                            type: integer
                            nullable: true
                          reason:
                            type: string
                          fits_budget:
                            type: boolean
                          stop_after_lesson:
                            type: boolean
                      counts:
                        type: object
                        description: 'LEGACY, may overlap. Badges for the All / In
                          Progress / Assigned / Completed tabs + Overdue banner, unchanged
                          for shipped apps: all but `all` OVERLAP (an assigned, in-progress
                          course counts in both In Progress and Assigned; an overdue
                          one in In Progress and Overdue). `all` is the union of the
                          others — every enrollment except cancelled — and equals
                          `meta.total_count` on `/training/my_training/enrollments?status=all`.
                          New clients read `bucket_counts`.'
                        required:
                        - all
                        - in_progress
                        - assigned
                        - completed
                        - overdue
                        properties:
                          all:
                            type: integer
                            example: 8
                          in_progress:
                            type: integer
                            description: Legacy
                            may overlap overdue.:
                            example: 6
                          assigned:
                            type: integer
                            description: Legacy — provenance
                            not a status; may overlap.:
                            example: 3
                          completed:
                            type: integer
                            example: 2
                          overdue:
                            type: integer
                            example: 2
                      bucket_counts:
                        type: object
                        description: 'The PARTITION of the learner''s open enrollments
                          the web My Learning chips use (Training::LearningStatus):
                          every open course or path is in exactly one bucket, overdue
                          first, so not_started + in_progress + overdue is every open
                          enrollment. Each equals `meta.total_count` on `/training/my_training/enrollments?bucket=<key>`.'
                        required:
                        - not_started
                        - in_progress
                        - overdue
                        properties:
                          not_started:
                            type: integer
                            description: Not begun and not overdue — self-enrolled
                              included.
                            example: 2
                          in_progress:
                            type: integer
                            description: Started and not overdue.
                            example: 3
                          overdue:
                            type: integer
                            description: Past due
                            started or not.:
                            example: 2
                      overdue:
                        type: object
                        required:
                        - count
                        - items
                        properties:
                          count:
                            type: integer
                            example: 2
                          items:
                            type: array
                            description: Most-overdue first; drives the banner text.
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 4412
                                type:
                                  type: string
                                  enum:
                                  - course
                                  - path
                                  example: course
                                title:
                                  type: string
                                  example: Product Knowledge
                                days_overdue:
                                  type: integer
                                  example: 15
                      external_training:
                        type: object
                        description: 'Courses still to do in the company''s OTHER
                          learning system (Training Connect: Workday, Cornerstone,
                          a custom LMS), soonest deadline first. Each is taken in
                          that system: open `start_url` in the browser. Empty (`total`
                          0) when the company connects no other system or nothing
                          is open there.'
                        required:
                        - total
                        - items
                        properties:
                          total:
                            type: integer
                            description: Open courses there
                            counted before the list is capped at 20.:
                            example: 3
                          items:
                            type: array
                            items:
                              type: object
                              required:
                              - id
                              - title
                              - overdue
                              - days_overdue
                              - in_progress
                              properties:
                                id:
                                  type: integer
                                  example: 918
                                title:
                                  type: string
                                  example: Sea Survival
                                due_date:
                                  type: string
                                  format: date
                                  nullable: true
                                  example: '2026-10-20'
                                overdue:
                                  type: boolean
                                  example: false
                                days_overdue:
                                  type: integer
                                  example: 0
                                in_progress:
                                  type: boolean
                                  description: true once the other system reports
                                    it started — label the button Resume.
                                  example: false
                                start_url:
                                  type: string
                                  format: uri
                                  nullable: true
                                  description: The course's page in the other system;
                                    null when the sync gave no web address (show no
                                    button).
                                  example: https://lms.example.com/courses/sea-survival
                      continue_learning:
                        type: array
                        description: Up to 4 recently-accessed active COURSE cards,
                          newest access first. Course-only by construction, so the
                          card's path-only fields (completed_steps / total_steps /
                          total_courses / current_step) never appear here; `next_lesson`
                          appears ONLY here (the list endpoint omits it).
                        items:
                          type: object
                          description: One course or path enrollment card. `type`
                            selects which progress fields are present.
                          required:
                          - id
                          - type
                          - status
                          - in_progress
                          - assigned
                          - completed
                          - overdue
                          - progress_percentage
                          - subject
                          properties:
                            id:
                              type: integer
                              example: 4412
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            status:
                              type: string
                              description: Raw enrollment status column. A course
                                fulfilled by an approved language edition reports
                                fulfilled; paths do not use that state.
                              enum:
                              - enrolled
                              - in_progress
                              - completed
                              - cancelled
                              - fulfilled
                              example: in_progress
                            status_key:
                              type: string
                              description: Learner-facing state (Training::Display)
                                — note a raw `enrolled` reads as not_started, exactly
                                as the web badge does.
                              enum:
                              - not_started
                              - in_progress
                              - completed
                              - registered
                              example: in_progress
                            status_label:
                              type: string
                              description: Display label — the badge word the web
                                and phone cards print (In Progress / Not Started /
                                Overdue / Completed / Registered / Cancelled). An
                                open enrollment past its due date reads Overdue here,
                                and a withdrawn attempt Cancelled, while status_key
                                keeps its own enum (not_started or in_progress).
                              example: In Progress
                            in_progress:
                              type: boolean
                              example: true
                            assigned:
                              type: boolean
                              description: Assigned by an admin/manager/rule (renders
                                the ASSIGNED chip).
                              example: true
                            completed:
                              type: boolean
                              description: Actual completion of this course or path.
                                Language fulfillment does not copy a completion.
                              example: false
                            satisfied:
                              type: boolean
                              description: Whether the assignment is complete or fulfilled
                                through an approved language edition.
                              example: false
                            fulfilled_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When approved language credit satisfied
                                this course assignment. Paths return null.
                            overdue:
                              type: boolean
                              example: false
                            progress_percentage:
                              type: integer
                              description: 0–100.
                              example: 50
                            due_date:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2026-08-15T00:00:00Z'
                            days_overdue:
                              type: integer
                              nullable: true
                              description: Present only when overdue.
                              example:
                            completed_at:
                              type: string
                              format: date-time
                              nullable: true
                              example:
                            last_accessed_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: Courses only.
                              example: '2026-08-12T14:03:00Z'
                            completed_lessons_count:
                              type: integer
                              description: Course cards only. Counted WITHIN `total_lessons`
                                — completions for lessons outside the learner's pinned
                                version, or outside the counted set, are excluded,
                                so this can never exceed the total.
                              example: 3
                            total_lessons:
                              type: integer
                              description: 'Course cards only. The lessons `progress_percentage`
                                is computed from: the REQUIRED, playable lessons of
                                the version the learner is PINNED to (falling back
                                to the current published version when not yet pinned)
                                — an optional lesson, or a partner or practice lesson
                                that cannot be played right now, is left out, so the
                                pair always agrees with the percentage. On a course
                                with no required lesson, every lesson the learner
                                can play (the course completes when they are done).
                                A completed enrollment reports `completed_lessons_count`
                                equal to `total_lessons`. The outline''s own row count
                                is `/training/courses/{id}/lessons`.'
                              example: 5
                            required_lessons_only:
                              type: boolean
                              description: Course cards only. True when `total_lessons`
                                leaves some of the outline out (an optional or unplayable
                                lesson), so a client can say "3/5 required lessons".
                              example: true
                            next_lesson:
                              type: object
                              nullable: true
                              description: Course cards only, and only in Continue-Learning
                                — the lesson to resume into.
                              properties:
                                id:
                                  type: integer
                                  example: 91
                                title:
                                  type: string
                                  example: Lockout/Tagout Procedure
                            completed_steps:
                              type: integer
                              description: Path cards only.
                              example: 1
                            total_steps:
                              type: integer
                              description: Path cards only.
                              example: 3
                            total_courses:
                              type: integer
                              description: Path cards only — courses in the path.
                              example: 8
                            current_step:
                              type: integer
                              description: Path cards only — 1-based "Step N of M"
                                (caps at total_steps).
                              example: 2
                            subject:
                              type: object
                              nullable: true
                              description: The course or path. Null for a hidden SYSTEM
                                course ("course unavailable").
                              required:
                              - id
                              - title
                              - type
                              - delivery_label
                              properties:
                                id:
                                  type: integer
                                  example: 320
                                title:
                                  type: string
                                  example: Workplace Safety Fundamentals
                                slug:
                                  type: string
                                  nullable: true
                                  example: workplace-safety-fundamentals
                                type:
                                  type: string
                                  enum:
                                  - course
                                  - path
                                  example: course
                                delivery_label:
                                  type: string
                                  description: Self-paced / Instructor-Led / Learning
                                    Path.
                                  enum:
                                  - Self-paced
                                  - Instructor-Led
                                  - Learning Path
                                  example: Self-paced
                                delivery_icon:
                                  type: string
                                  description: Font Awesome glyph for the delivery
                                    type — the web banner's type fallback (fa-circle-play
                                    / fa-chalkboard-user / fa-route).
                                  example: fa-circle-play
                                category:
                                  type: object
                                  nullable: true
                                  properties:
                                    name:
                                      type: string
                                      example: Safety
                                    color:
                                      type: string
                                      nullable: true
                                      description: Category color (hex).
                                      example: "#28a745"
                                    icon:
                                      type: string
                                      nullable: true
                                      description: Font Awesome glyph.
                                      example: fa-shield-alt
                                hero_image_url:
                                  type: string
                                  nullable: true
                                  description: Absolute URL of a resized hero image,
                                    or null.
                                  example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/my_training/enrollments":
    get:
      tags:
      - Training
      summary: My Training course + path list
      description: |
        The "My Training" list — course AND training-path enrollments merged into
        one feed, newest enrollment first (or soonest due with `sort=due`),
        matching the prototype's mixed list.
        Serves the segmented filter: All / In Progress / Assigned / Completed /
        Overdue (`status`, legacy), and the web chips' partition (`bucket`). Every
        `status` value takes the same `page`/`per_page` contract
        and returns the same `enrollments` + `counts` + `meta` shape, so a client
        can back any set of tabs with one request shape.

        `counts` badges the tabs + overdue banner (same math as
        `/training/my_training`). `meta` drives pagination. Each row is a
        `TrainingCard` — a `type: course` card carries `completed_lessons_count`/
        `total_lessons`; a `type: path` card carries `completed_steps`/
        `total_steps`/`total_courses`/`current_step` (the prototype's
        "Step 2 of 3 · 8 courses").

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: LEGACY, may overlap. Which tab to load. Unknown values resolve
          to `in_progress`. `all` is the learner's WHOLE list — the union of every
          other value (every enrollment except cancelled, which is withdrawn audit
          state) — so it can never come back smaller than one of them. `overdue` is
          past-due and not completed; `in_progress` and `assigned` include overdue
          rows, and `assigned` is provenance. Every value takes the same `page`/`per_page`
          contract and returns the same `enrollments` + `counts` + `meta` shape. Ignored
          when `bucket` is given.
        schema:
          type: string
          enum:
          - in_progress
          - assigned
          - completed
          - overdue
          - all
          default: in_progress
          example: in_progress
      - name: bucket
        in: query
        description: 'One bucket of the web chips'' PARTITION of the open list (Training::LearningStatus):
          `not_started` (self-enrolled included), `in_progress` or `overdue` — each
          open course or path is in exactly one, overdue first. When given, the list
          is that bucket and `status` is not applied; `active_bucket` echoes it and
          `bucket_counts` badges it. An unknown value is ignored (the `status` list
          is returned).'
        schema:
          type: string
          enum:
          - not_started
          - in_progress
          - overdue
          example: not_started
      - name: sort
        in: query
        description: 'The order of the list — the web and phone My Learning''s Sort
          by (Training::LearnerListOrder). `newest` (the default, and this list''s
          only order before the parameter existed): newest assigned first, the enrollment''s
          creation time, courses and paths interleaved. `due`: soonest due date first,
          so overdue rows lead; open rows with no due date follow every dated one,
          and completed rows come last, newest first within each. It orders the page
          only — `counts`, `bucket_counts` and `meta.total_count` are the same either
          way. An unknown value is `newest`; `active_sort` echoes the one applied.'
        schema:
          type: string
          enum:
          - newest
          - due
          default: newest
          example: newest
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Enrollment list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - enrollments
                - active_status
                - active_bucket
                - active_sort
                - counts
                - bucket_counts
                - meta
                properties:
                  enrollments:
                    type: array
                    items:
                      type: object
                      description: One course or path enrollment card. `type` selects
                        which progress fields are present.
                      required:
                      - id
                      - type
                      - status
                      - in_progress
                      - assigned
                      - completed
                      - overdue
                      - progress_percentage
                      - subject
                      properties:
                        id:
                          type: integer
                          example: 4412
                        type:
                          type: string
                          enum:
                          - course
                          - path
                          example: course
                        status:
                          type: string
                          description: Raw enrollment status column. A course fulfilled
                            by an approved language edition reports fulfilled; paths
                            do not use that state.
                          enum:
                          - enrolled
                          - in_progress
                          - completed
                          - cancelled
                          - fulfilled
                          example: in_progress
                        status_key:
                          type: string
                          description: Learner-facing state (Training::Display) —
                            note a raw `enrolled` reads as not_started, exactly as
                            the web badge does.
                          enum:
                          - not_started
                          - in_progress
                          - completed
                          - registered
                          example: in_progress
                        status_label:
                          type: string
                          description: Display label — the badge word the web and
                            phone cards print (In Progress / Not Started / Overdue
                            / Completed / Registered / Cancelled). An open enrollment
                            past its due date reads Overdue here, and a withdrawn
                            attempt Cancelled, while status_key keeps its own enum
                            (not_started or in_progress).
                          example: In Progress
                        in_progress:
                          type: boolean
                          example: true
                        assigned:
                          type: boolean
                          description: Assigned by an admin/manager/rule (renders
                            the ASSIGNED chip).
                          example: true
                        completed:
                          type: boolean
                          description: Actual completion of this course or path. Language
                            fulfillment does not copy a completion.
                          example: false
                        satisfied:
                          type: boolean
                          description: Whether the assignment is complete or fulfilled
                            through an approved language edition.
                          example: false
                        fulfilled_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When approved language credit satisfied this
                            course assignment. Paths return null.
                        overdue:
                          type: boolean
                          example: false
                        progress_percentage:
                          type: integer
                          description: 0–100.
                          example: 50
                        due_date:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-08-15T00:00:00Z'
                        days_overdue:
                          type: integer
                          nullable: true
                          description: Present only when overdue.
                          example:
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          example:
                        last_accessed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Courses only.
                          example: '2026-08-12T14:03:00Z'
                        completed_lessons_count:
                          type: integer
                          description: Course cards only. Counted WITHIN `total_lessons`
                            — completions for lessons outside the learner's pinned
                            version, or outside the counted set, are excluded, so
                            this can never exceed the total.
                          example: 3
                        total_lessons:
                          type: integer
                          description: 'Course cards only. The lessons `progress_percentage`
                            is computed from: the REQUIRED, playable lessons of the
                            version the learner is PINNED to (falling back to the
                            current published version when not yet pinned) — an optional
                            lesson, or a partner or practice lesson that cannot be
                            played right now, is left out, so the pair always agrees
                            with the percentage. On a course with no required lesson,
                            every lesson the learner can play (the course completes
                            when they are done). A completed enrollment reports `completed_lessons_count`
                            equal to `total_lessons`. The outline''s own row count
                            is `/training/courses/{id}/lessons`.'
                          example: 5
                        required_lessons_only:
                          type: boolean
                          description: Course cards only. True when `total_lessons`
                            leaves some of the outline out (an optional or unplayable
                            lesson), so a client can say "3/5 required lessons".
                          example: true
                        next_lesson:
                          type: object
                          nullable: true
                          description: Course cards only, and only in Continue-Learning
                            — the lesson to resume into.
                          properties:
                            id:
                              type: integer
                              example: 91
                            title:
                              type: string
                              example: Lockout/Tagout Procedure
                        completed_steps:
                          type: integer
                          description: Path cards only.
                          example: 1
                        total_steps:
                          type: integer
                          description: Path cards only.
                          example: 3
                        total_courses:
                          type: integer
                          description: Path cards only — courses in the path.
                          example: 8
                        current_step:
                          type: integer
                          description: Path cards only — 1-based "Step N of M" (caps
                            at total_steps).
                          example: 2
                        subject:
                          type: object
                          nullable: true
                          description: The course or path. Null for a hidden SYSTEM
                            course ("course unavailable").
                          required:
                          - id
                          - title
                          - type
                          - delivery_label
                          properties:
                            id:
                              type: integer
                              example: 320
                            title:
                              type: string
                              example: Workplace Safety Fundamentals
                            slug:
                              type: string
                              nullable: true
                              example: workplace-safety-fundamentals
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            delivery_label:
                              type: string
                              description: Self-paced / Instructor-Led / Learning
                                Path.
                              enum:
                              - Self-paced
                              - Instructor-Led
                              - Learning Path
                              example: Self-paced
                            delivery_icon:
                              type: string
                              description: Font Awesome glyph for the delivery type
                                — the web banner's type fallback (fa-circle-play /
                                fa-chalkboard-user / fa-route).
                              example: fa-circle-play
                            category:
                              type: object
                              nullable: true
                              properties:
                                name:
                                  type: string
                                  example: Safety
                                color:
                                  type: string
                                  nullable: true
                                  description: Category color (hex).
                                  example: "#28a745"
                                icon:
                                  type: string
                                  nullable: true
                                  description: Font Awesome glyph.
                                  example: fa-shield-alt
                            hero_image_url:
                              type: string
                              nullable: true
                              description: Absolute URL of a resized hero image, or
                                null.
                              example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  active_status:
                    type: string
                    nullable: true
                    enum:
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    - all
                    description: The `status` this page lists; null when `bucket`
                      drove the list instead.
                    example: in_progress
                  active_bucket:
                    type: string
                    nullable: true
                    enum:
                    - not_started
                    - in_progress
                    - overdue
                    description: The `bucket` this page lists; null when the `status`
                      list was returned.
                    example:
                  active_sort:
                    type: string
                    enum:
                    - newest
                    - due
                    description: The order this page is in (`sort`, defaulting to
                      `newest`).
                    example: newest
                  counts:
                    type: object
                    description: 'LEGACY, may overlap. Badges for the All / In Progress
                      / Assigned / Completed tabs + Overdue banner, unchanged for
                      shipped apps: all but `all` OVERLAP (an assigned, in-progress
                      course counts in both In Progress and Assigned; an overdue one
                      in In Progress and Overdue). `all` is the union of the others
                      — every enrollment except cancelled — and equals `meta.total_count`
                      on `/training/my_training/enrollments?status=all`. New clients
                      read `bucket_counts`.'
                    required:
                    - all
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    properties:
                      all:
                        type: integer
                        example: 8
                      in_progress:
                        type: integer
                        description: Legacy
                        may overlap overdue.:
                        example: 6
                      assigned:
                        type: integer
                        description: Legacy — provenance
                        not a status; may overlap.:
                        example: 3
                      completed:
                        type: integer
                        example: 2
                      overdue:
                        type: integer
                        example: 2
                  bucket_counts:
                    type: object
                    description: 'The PARTITION of the learner''s open enrollments
                      the web My Learning chips use (Training::LearningStatus): every
                      open course or path is in exactly one bucket, overdue first,
                      so not_started + in_progress + overdue is every open enrollment.
                      Each equals `meta.total_count` on `/training/my_training/enrollments?bucket=<key>`.'
                    required:
                    - not_started
                    - in_progress
                    - overdue
                    properties:
                      not_started:
                        type: integer
                        description: Not begun and not overdue — self-enrolled included.
                        example: 2
                      in_progress:
                        type: integer
                        description: Started and not overdue.
                        example: 3
                      overdue:
                        type: integer
                        description: Past due
                        started or not.:
                        example: 2
                  meta:
                    type: object
                    description: Pagination over the active filter (build_pagination_meta).
                    properties:
                      total_count:
                        type: integer
                        example: 6
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/courses/{course_id}/enroll":
    post:
      tags:
      - Training
      summary: Self-enroll in a course
      description: |
        The action behind the catalog's own `enroll` and `start_path` CTAs
        (`cta_action` on a catalog card), which named an operation that had no
        native endpoint until now.

        ONE operation, two routes — `/training/courses/{course_id}/enroll` and
        `/training/learning_paths/{learning_path_id}/enroll` — resolved from
        whichever id the route carried, the same shape the reviews and Q&A
        writes use. No request body.

        **IDEMPOTENT.** A repeat (double-tapped button, retried request on a
        flaky connection) returns `200` with `created: false` and
        `already_enrolled: true`, NOT an error — so a client may retry safely.
        `created` is the flag to branch on; the accompanying `message` is
        already worded for either case.

        **Both self-enrollment switches must be on**, exactly as every
        learner-facing surface reads them: the tenant-wide Training setting AND
        the per-subject admin toggle. Note the two subject defaults differ — a
        course created while the tenant setting is on allows self-enrollment
        unless an admin turns it off (one created while it is off starts with it
        off), a learning path denies it unless an admin turns it on — so the same
        tenant can legitimately answer 200 for a course and 403 for a path.

        **Courses additionally enforce prerequisites**; learning paths have no
        prerequisites concept, so that refusal cannot occur for a path. The 422
        carries the blocking courses in `error.details.missing_prerequisites`
        so the client can name them rather than render a bare refusal.

        Enrolling in a PATH creates only the path enrollment — its member
        courses enroll lazily when the learner opens them, so do not expect
        course rows to appear in `/training/my_training/enrollments` yet.

        The returned `enrollment` is the same `TrainingCard` the My Training
        list renders, so a client can insert it into that list without a
        refetch.

        **Required Scopes:** `write:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: write:training")
        before anything else is checked. The sign-in tokens hold it; a browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        description: The course to enroll in. Digit-constrained.
        schema:
          type: integer
          example: 128
      responses:
        '200':
          description: 'Enrolled, or already enrolled — branch on `created`.

            '
          content:
            application/json:
              schema:
                type: object
                required:
                - created
                - already_enrolled
                - message
                - enrollment
                properties:
                  created:
                    type: boolean
                    description: True when this request created the enrollment; false
                      on an idempotent repeat.
                    example: true
                  already_enrolled:
                    type: boolean
                    description: The inverse of `created`, for a client that reads
                      the state rather than the event.
                    example: false
                  message:
                    type: string
                    description: Learner-facing copy, already worded for the created
                      / already-enrolled case.
                    example: Successfully enrolled!
                  enrollment:
                    type: object
                    description: One course or path enrollment card. `type` selects
                      which progress fields are present.
                    required:
                    - id
                    - type
                    - status
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    - progress_percentage
                    - subject
                    properties:
                      id:
                        type: integer
                        example: 4412
                      type:
                        type: string
                        enum:
                        - course
                        - path
                        example: course
                      status:
                        type: string
                        description: Raw enrollment status column. A course fulfilled
                          by an approved language edition reports fulfilled; paths
                          do not use that state.
                        enum:
                        - enrolled
                        - in_progress
                        - completed
                        - cancelled
                        - fulfilled
                        example: in_progress
                      status_key:
                        type: string
                        description: Learner-facing state (Training::Display) — note
                          a raw `enrolled` reads as not_started, exactly as the web
                          badge does.
                        enum:
                        - not_started
                        - in_progress
                        - completed
                        - registered
                        example: in_progress
                      status_label:
                        type: string
                        description: Display label — the badge word the web and phone
                          cards print (In Progress / Not Started / Overdue / Completed
                          / Registered / Cancelled). An open enrollment past its due
                          date reads Overdue here, and a withdrawn attempt Cancelled,
                          while status_key keeps its own enum (not_started or in_progress).
                        example: In Progress
                      in_progress:
                        type: boolean
                        example: true
                      assigned:
                        type: boolean
                        description: Assigned by an admin/manager/rule (renders the
                          ASSIGNED chip).
                        example: true
                      completed:
                        type: boolean
                        description: Actual completion of this course or path. Language
                          fulfillment does not copy a completion.
                        example: false
                      satisfied:
                        type: boolean
                        description: Whether the assignment is complete or fulfilled
                          through an approved language edition.
                        example: false
                      fulfilled_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: When approved language credit satisfied this
                          course assignment. Paths return null.
                      overdue:
                        type: boolean
                        example: false
                      progress_percentage:
                        type: integer
                        description: 0–100.
                        example: 50
                      due_date:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-08-15T00:00:00Z'
                      days_overdue:
                        type: integer
                        nullable: true
                        description: Present only when overdue.
                        example:
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example:
                      last_accessed_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Courses only.
                        example: '2026-08-12T14:03:00Z'
                      completed_lessons_count:
                        type: integer
                        description: Course cards only. Counted WITHIN `total_lessons`
                          — completions for lessons outside the learner's pinned version,
                          or outside the counted set, are excluded, so this can never
                          exceed the total.
                        example: 3
                      total_lessons:
                        type: integer
                        description: 'Course cards only. The lessons `progress_percentage`
                          is computed from: the REQUIRED, playable lessons of the
                          version the learner is PINNED to (falling back to the current
                          published version when not yet pinned) — an optional lesson,
                          or a partner or practice lesson that cannot be played right
                          now, is left out, so the pair always agrees with the percentage.
                          On a course with no required lesson, every lesson the learner
                          can play (the course completes when they are done). A completed
                          enrollment reports `completed_lessons_count` equal to `total_lessons`.
                          The outline''s own row count is `/training/courses/{id}/lessons`.'
                        example: 5
                      required_lessons_only:
                        type: boolean
                        description: Course cards only. True when `total_lessons`
                          leaves some of the outline out (an optional or unplayable
                          lesson), so a client can say "3/5 required lessons".
                        example: true
                      next_lesson:
                        type: object
                        nullable: true
                        description: Course cards only, and only in Continue-Learning
                          — the lesson to resume into.
                        properties:
                          id:
                            type: integer
                            example: 91
                          title:
                            type: string
                            example: Lockout/Tagout Procedure
                      completed_steps:
                        type: integer
                        description: Path cards only.
                        example: 1
                      total_steps:
                        type: integer
                        description: Path cards only.
                        example: 3
                      total_courses:
                        type: integer
                        description: Path cards only — courses in the path.
                        example: 8
                      current_step:
                        type: integer
                        description: Path cards only — 1-based "Step N of M" (caps
                          at total_steps).
                        example: 2
                      subject:
                        type: object
                        nullable: true
                        description: The course or path. Null for a hidden SYSTEM
                          course ("course unavailable").
                        required:
                        - id
                        - title
                        - type
                        - delivery_label
                        properties:
                          id:
                            type: integer
                            example: 320
                          title:
                            type: string
                            example: Workplace Safety Fundamentals
                          slug:
                            type: string
                            nullable: true
                            example: workplace-safety-fundamentals
                          type:
                            type: string
                            enum:
                            - course
                            - path
                            example: course
                          delivery_label:
                            type: string
                            description: Self-paced / Instructor-Led / Learning Path.
                            enum:
                            - Self-paced
                            - Instructor-Led
                            - Learning Path
                            example: Self-paced
                          delivery_icon:
                            type: string
                            description: Font Awesome glyph for the delivery type
                              — the web banner's type fallback (fa-circle-play / fa-chalkboard-user
                              / fa-route).
                            example: fa-circle-play
                          category:
                            type: object
                            nullable: true
                            properties:
                              name:
                                type: string
                                example: Safety
                              color:
                                type: string
                                nullable: true
                                description: Category color (hex).
                                example: "#28a745"
                              icon:
                                type: string
                                nullable: true
                                description: Font Awesome glyph.
                                example: fa-shield-alt
                          hero_image_url:
                            type: string
                            nullable: true
                            description: Absolute URL of a resized hero image, or
                              null.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `write:training` scope (`insufficient_permissions`); or
            self-enrollment is off (`self_enrollment_disabled`) — the message
            names WHICH switch, since one is a tenant setting an admin controls
            and the other is a property of the course or path.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            Prerequisites are not met (`prerequisites_not_met`, courses only —
            `error.details.missing_prerequisites` lists the blocking courses), or
            the enrollment could not be written (`enrollment_failed`).
  "/training/learning_paths/{learning_path_id}/enroll":
    post:
      tags:
      - Training
      summary: Self-enroll in a learning path
      description: |
        The action behind the catalog's own `enroll` and `start_path` CTAs
        (`cta_action` on a catalog card), which named an operation that had no
        native endpoint until now.

        ONE operation, two routes — `/training/courses/{course_id}/enroll` and
        `/training/learning_paths/{learning_path_id}/enroll` — resolved from
        whichever id the route carried, the same shape the reviews and Q&A
        writes use. No request body.

        **IDEMPOTENT.** A repeat (double-tapped button, retried request on a
        flaky connection) returns `200` with `created: false` and
        `already_enrolled: true`, NOT an error — so a client may retry safely.
        `created` is the flag to branch on; the accompanying `message` is
        already worded for either case.

        **Both self-enrollment switches must be on**, exactly as every
        learner-facing surface reads them: the tenant-wide Training setting AND
        the per-subject admin toggle. Note the two subject defaults differ — a
        course created while the tenant setting is on allows self-enrollment
        unless an admin turns it off (one created while it is off starts with it
        off), a learning path denies it unless an admin turns it on — so the same
        tenant can legitimately answer 200 for a course and 403 for a path.

        **Courses additionally enforce prerequisites**; learning paths have no
        prerequisites concept, so that refusal cannot occur for a path. The 422
        carries the blocking courses in `error.details.missing_prerequisites`
        so the client can name them rather than render a bare refusal.

        Enrolling in a PATH creates only the path enrollment — its member
        courses enroll lazily when the learner opens them, so do not expect
        course rows to appear in `/training/my_training/enrollments` yet.

        The returned `enrollment` is the same `TrainingCard` the My Training
        list renders, so a client can insert it into that list without a
        refetch.

        **Required Scopes:** `write:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"). A token without it gets
        `403 insufficient_permissions` ("Required scope: write:training")
        before anything else is checked. The sign-in tokens hold it; a browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        description: The learning path to start. Digit-constrained.
        schema:
          type: integer
          example: 6
      responses:
        '200':
          description: 'Enrolled, or already enrolled — branch on `created`.

            '
          content:
            application/json:
              schema:
                type: object
                required:
                - created
                - already_enrolled
                - message
                - enrollment
                properties:
                  created:
                    type: boolean
                    description: True when this request created the enrollment; false
                      on an idempotent repeat.
                    example: true
                  already_enrolled:
                    type: boolean
                    description: The inverse of `created`, for a client that reads
                      the state rather than the event.
                    example: false
                  message:
                    type: string
                    description: Learner-facing copy, already worded for the created
                      / already-enrolled case.
                    example: Successfully enrolled!
                  enrollment:
                    type: object
                    description: One course or path enrollment card. `type` selects
                      which progress fields are present.
                    required:
                    - id
                    - type
                    - status
                    - in_progress
                    - assigned
                    - completed
                    - overdue
                    - progress_percentage
                    - subject
                    properties:
                      id:
                        type: integer
                        example: 4412
                      type:
                        type: string
                        enum:
                        - course
                        - path
                        example: course
                      status:
                        type: string
                        description: Raw enrollment status column. A course fulfilled
                          by an approved language edition reports fulfilled; paths
                          do not use that state.
                        enum:
                        - enrolled
                        - in_progress
                        - completed
                        - cancelled
                        - fulfilled
                        example: in_progress
                      status_key:
                        type: string
                        description: Learner-facing state (Training::Display) — note
                          a raw `enrolled` reads as not_started, exactly as the web
                          badge does.
                        enum:
                        - not_started
                        - in_progress
                        - completed
                        - registered
                        example: in_progress
                      status_label:
                        type: string
                        description: Display label — the badge word the web and phone
                          cards print (In Progress / Not Started / Overdue / Completed
                          / Registered / Cancelled). An open enrollment past its due
                          date reads Overdue here, and a withdrawn attempt Cancelled,
                          while status_key keeps its own enum (not_started or in_progress).
                        example: In Progress
                      in_progress:
                        type: boolean
                        example: true
                      assigned:
                        type: boolean
                        description: Assigned by an admin/manager/rule (renders the
                          ASSIGNED chip).
                        example: true
                      completed:
                        type: boolean
                        description: Actual completion of this course or path. Language
                          fulfillment does not copy a completion.
                        example: false
                      satisfied:
                        type: boolean
                        description: Whether the assignment is complete or fulfilled
                          through an approved language edition.
                        example: false
                      fulfilled_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: When approved language credit satisfied this
                          course assignment. Paths return null.
                      overdue:
                        type: boolean
                        example: false
                      progress_percentage:
                        type: integer
                        description: 0–100.
                        example: 50
                      due_date:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-08-15T00:00:00Z'
                      days_overdue:
                        type: integer
                        nullable: true
                        description: Present only when overdue.
                        example:
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example:
                      last_accessed_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Courses only.
                        example: '2026-08-12T14:03:00Z'
                      completed_lessons_count:
                        type: integer
                        description: Course cards only. Counted WITHIN `total_lessons`
                          — completions for lessons outside the learner's pinned version,
                          or outside the counted set, are excluded, so this can never
                          exceed the total.
                        example: 3
                      total_lessons:
                        type: integer
                        description: 'Course cards only. The lessons `progress_percentage`
                          is computed from: the REQUIRED, playable lessons of the
                          version the learner is PINNED to (falling back to the current
                          published version when not yet pinned) — an optional lesson,
                          or a partner or practice lesson that cannot be played right
                          now, is left out, so the pair always agrees with the percentage.
                          On a course with no required lesson, every lesson the learner
                          can play (the course completes when they are done). A completed
                          enrollment reports `completed_lessons_count` equal to `total_lessons`.
                          The outline''s own row count is `/training/courses/{id}/lessons`.'
                        example: 5
                      required_lessons_only:
                        type: boolean
                        description: Course cards only. True when `total_lessons`
                          leaves some of the outline out (an optional or unplayable
                          lesson), so a client can say "3/5 required lessons".
                        example: true
                      next_lesson:
                        type: object
                        nullable: true
                        description: Course cards only, and only in Continue-Learning
                          — the lesson to resume into.
                        properties:
                          id:
                            type: integer
                            example: 91
                          title:
                            type: string
                            example: Lockout/Tagout Procedure
                      completed_steps:
                        type: integer
                        description: Path cards only.
                        example: 1
                      total_steps:
                        type: integer
                        description: Path cards only.
                        example: 3
                      total_courses:
                        type: integer
                        description: Path cards only — courses in the path.
                        example: 8
                      current_step:
                        type: integer
                        description: Path cards only — 1-based "Step N of M" (caps
                          at total_steps).
                        example: 2
                      subject:
                        type: object
                        nullable: true
                        description: The course or path. Null for a hidden SYSTEM
                          course ("course unavailable").
                        required:
                        - id
                        - title
                        - type
                        - delivery_label
                        properties:
                          id:
                            type: integer
                            example: 320
                          title:
                            type: string
                            example: Workplace Safety Fundamentals
                          slug:
                            type: string
                            nullable: true
                            example: workplace-safety-fundamentals
                          type:
                            type: string
                            enum:
                            - course
                            - path
                            example: course
                          delivery_label:
                            type: string
                            description: Self-paced / Instructor-Led / Learning Path.
                            enum:
                            - Self-paced
                            - Instructor-Led
                            - Learning Path
                            example: Self-paced
                          delivery_icon:
                            type: string
                            description: Font Awesome glyph for the delivery type
                              — the web banner's type fallback (fa-circle-play / fa-chalkboard-user
                              / fa-route).
                            example: fa-circle-play
                          category:
                            type: object
                            nullable: true
                            properties:
                              name:
                                type: string
                                example: Safety
                              color:
                                type: string
                                nullable: true
                                description: Category color (hex).
                                example: "#28a745"
                              icon:
                                type: string
                                nullable: true
                                description: Font Awesome glyph.
                                example: fa-shield-alt
                          hero_image_url:
                            type: string
                            nullable: true
                            description: Absolute URL of a resized hero image, or
                              null.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `write:training` scope (`insufficient_permissions`); or
            self-enrollment is off (`self_enrollment_disabled`) — the message
            names WHICH switch, since one is a tenant setting an admin controls
            and the other is a property of the course or path.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            Prerequisites are not met (`prerequisites_not_met`, courses only —
            `error.details.missing_prerequisites` lists the blocking courses), or
            the enrollment could not be written (`enrollment_failed`).
  "/training/courses/{course_id}/enrollment":
    delete:
      tags:
      - Training
      summary: Leave a self-enrolled course
      description: |
        **Requires the `destructive:training` scope** — a delete-level act
        (#7183, #6721 "classify by the act"). No request body.

        The learner's OWN withdrawal — the undo of `POST .../enroll`, and the
        native twin of the web's `DELETE learner/courses/{id}/withdraw` and
        `DELETE learner/learning_paths/{id}/withdraw`. ONE operation, two
        routes — `/training/courses/{course_id}/enrollment` and
        `/training/learning_paths/{learning_path_id}/enrollment` — resolved
        from whichever id the route carried, exactly as the enroll pair is.

        **DELETE on the ENROLLMENT sub-resource, not on the course** (the same
        shape as `DELETE /training/sessions/{id}/registration`): the course is
        not being removed, the caller's enrollment in it is. Only the caller's
        own CURRENT enrollment is ever touched — there is no way to name
        somebody else's.

        **Deliberately narrow, because Training is the compliance system of
        record.** The rule is `Training::EnrollmentCancellationService
        .withdrawal_refusal`, the same one the web's withdraw actions and their
        sidebar buttons read, so mobile and web cannot disagree on who may
        leave what. It refuses, with `403 withdrawal_refused` and a
        learner-facing message naming why:

        * anything ASSIGNED — by a person, a training assignment, an automation
          rule, or a covering learning path (`enrollment.assigned` on the
          course detail / `assigned` on the card is the hint to hide the
          control) — "only an administrator can remove it";
        * a completed enrollment, or one holding a certificate — permanent
          history;
        * any enrollment when the tenant has self-enrollment turned off.

        A course withdrawal CLEARS the learner's lesson completions and quiz
        attempts on it (a self-paced course) or RELEASES the seat with waitlist
        promotion (an instructor-led one); the cancelled row is kept for audit.
        Both are irreversible — confirm before calling. Leaving a PATH does not
        cancel the member courses already started from it: those stay in
        My Training (and, having come from the path, cannot be dropped
        individually either), and the `message` says so with the count.

        After a successful withdrawal the learner may enroll again through
        `POST .../enroll` — the cancelled attempt is retired, not left blocking.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        description: The course to leave. Digit-constrained.
        schema:
          type: integer
          example: 128
      responses:
        '200':
          description: Withdrawn. `message` is learner-facing copy already worded
            for the delivery mode / kept-courses case.
          content:
            application/json:
              schema:
                type: object
                required:
                - withdrawn
                - message
                - enrollment
                properties:
                  withdrawn:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: You've been removed from "Forklift Refresher". Any progress
                      you'd made on it has been cleared.
                  enrollment:
                    type: object
                    description: The cancelled row, so a client holding the My Training
                      card it came from can drop that card without a refetch.
                    required:
                    - id
                    - status
                    properties:
                      id:
                        type: integer
                        example: 4412
                      status:
                        type: string
                        enum:
                        - cancelled
                        example: cancelled
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `destructive:training` scope (`insufficient_permissions`); or the
            learner may not withdraw from THIS enrollment (`withdrawal_refused`)
            — it was assigned, is completed or certificated, or self-enrollment
            is off for the tenant. The message names which, in learner-facing
            copy identical to the web's.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            The caller holds no current enrollment in it (`not_enrolled`), or the
            cancellation could not be written (`withdrawal_failed`).
  "/training/learning_paths/{learning_path_id}/enrollment":
    delete:
      tags:
      - Training
      summary: Leave a self-enrolled learning path
      description: |
        **Requires the `destructive:training` scope** — a delete-level act
        (#7183, #6721 "classify by the act"). No request body.

        The learner's OWN withdrawal — the undo of `POST .../enroll`, and the
        native twin of the web's `DELETE learner/courses/{id}/withdraw` and
        `DELETE learner/learning_paths/{id}/withdraw`. ONE operation, two
        routes — `/training/courses/{course_id}/enrollment` and
        `/training/learning_paths/{learning_path_id}/enrollment` — resolved
        from whichever id the route carried, exactly as the enroll pair is.

        **DELETE on the ENROLLMENT sub-resource, not on the course** (the same
        shape as `DELETE /training/sessions/{id}/registration`): the course is
        not being removed, the caller's enrollment in it is. Only the caller's
        own CURRENT enrollment is ever touched — there is no way to name
        somebody else's.

        **Deliberately narrow, because Training is the compliance system of
        record.** The rule is `Training::EnrollmentCancellationService
        .withdrawal_refusal`, the same one the web's withdraw actions and their
        sidebar buttons read, so mobile and web cannot disagree on who may
        leave what. It refuses, with `403 withdrawal_refused` and a
        learner-facing message naming why:

        * anything ASSIGNED — by a person, a training assignment, an automation
          rule, or a covering learning path (`enrollment.assigned` on the
          course detail / `assigned` on the card is the hint to hide the
          control) — "only an administrator can remove it";
        * a completed enrollment, or one holding a certificate — permanent
          history;
        * any enrollment when the tenant has self-enrollment turned off.

        A course withdrawal CLEARS the learner's lesson completions and quiz
        attempts on it (a self-paced course) or RELEASES the seat with waitlist
        promotion (an instructor-led one); the cancelled row is kept for audit.
        Both are irreversible — confirm before calling. Leaving a PATH does not
        cancel the member courses already started from it: those stay in
        My Training (and, having come from the path, cannot be dropped
        individually either), and the `message` says so with the count.

        After a successful withdrawal the learner may enroll again through
        `POST .../enroll` — the cancelled attempt is retired, not left blocking.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        description: The learning path to leave. Digit-constrained.
        schema:
          type: integer
          example: 6
      responses:
        '200':
          description: Withdrawn. `message` is learner-facing copy already worded
            for the delivery mode / kept-courses case.
          content:
            application/json:
              schema:
                type: object
                required:
                - withdrawn
                - message
                - enrollment
                properties:
                  withdrawn:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: You've been removed from "Forklift Refresher". Any progress
                      you'd made on it has been cleared.
                  enrollment:
                    type: object
                    description: The cancelled row, so a client holding the My Training
                      card it came from can drop that card without a refetch.
                    required:
                    - id
                    - status
                    properties:
                      id:
                        type: integer
                        example: 4412
                      status:
                        type: string
                        enum:
                        - cancelled
                        example: cancelled
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            The Training app is not accessible (`access_denied`); the token lacks
            the `destructive:training` scope (`insufficient_permissions`); or the
            learner may not withdraw from THIS enrollment (`withdrawal_refused`)
            — it was assigned, is completed or certificated, or self-enrollment
            is off for the tenant. The message names which, in learner-facing
            copy identical to the web's.
        '404':
          description: No such course or learning path, or it is not visible to this
            caller (`not_found`).
        '422':
          description: |
            The caller holds no current enrollment in it (`not_enrolled`), or the
            cancellation could not be written (`withdrawal_failed`).
  "/training/catalog":
    get:
      tags:
      - Training
      summary: Course + learning-path catalog
      description: |
        The Catalog tab — browses published COURSES and LEARNING PATHS together
        (courses first, then paths). Only what the signed-in learner can get into is
        listed: an item with self-enrollment off — its own switch, or the
        organization's Allow Self-Enrollment setting in Training Settings — appears
        only to learners enrolled in it, and a paid course stays listed so it can be
        bought. A course and the editions linked to it as its translations (Course ›
        Languages) are one item: the edition in the language the learner reads
        content in, else the original — unless the learner's catalog lists neither,
        when the editions it does list stay. An edition the learner already holds is
        always listed. `counts` and
        `/training/catalog/categories` follow the same rule. Reconciled to the mobile
        prototype's controls:
        a `type` segmented tab bar (with per-type `counts`), a `category` filter, a
        `duration` bucket, and `sort`. Paginated.

        Cards are enrollment-aware (the prototype's Continue / Enroll / Register /
        Start-path CTAs): each carries `enrollment_status` (null when not enrolled)
        and a derived `cta_action`. A `type: course` card carries `lessons_count`
        (or `sessions_count` for instructor-led) / `rating` / `free` / `price`; a
        `type: path` card carries `steps_count` / `courses_count`. `counts` badges
        the type tabs; `filters` echoes the applied values.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: |-
          Free-text search over TITLE and DESCRIPTION, for courses AND paths, matched case-insensitively as a substring. Runs the models' own `search` scopes — the SAME predicate the web learner catalog and the `/m/` catalog run — so one term cannot return three different result sets across surfaces.

          NO RANKING, deliberately: results come back in the requested `sort` order, not by relevance, exactly as the web behaves. `sort=relevant` is the catalog's default order, not a relevance score.

          Blank or missing is a NO-OP, not an error — the full catalog comes back with a 200 — so a client may call this on every keystroke. A crafted `?q[]=x` is ignored the same way rather than searching for the literal text `["x"]`.

          COMPOSES WITH EVERY OTHER FILTER (AND), and it narrows `counts` — see there.
        schema:
          type: string
          example: safety
      - name: type
        in: query
        description: Narrow to one delivery type. Blank or unrecognised = all (and
          `filters.type` reports null, never an unapplied value). Segmented tabs;
          see `counts`.
        schema:
          type: string
          enum:
          - self_paced
          - instructor_led
          - learning_path
          example: self_paced
      - name: category
        in: query
        description: A category NAME (case-insensitive; courses match the free-text
          column, paths match their TrainingCategory name). See /training/catalog/categories.
        schema:
          type: string
          example: Safety
      - name: duration
        in: query
        description: Duration bucket over the displayed total (Under 30 min / 30–60
          min / Over 1 hour). Items with an unknown/zero duration match no bucket.
        schema:
          type: string
          enum:
          - under_30
          - '30_60'
          - over_60
          example: under_30
      - name: sort
        in: query
        description: relevant (default; catalog order, what the caller has already
          finished last) · newest (published/created desc) · shortest (duration asc)
          · due_date (caller's enrollment due date asc, unenrolled last).
        schema:
          type: string
          enum:
          - relevant
          - newest
          - shortest
          - due_date
          default: relevant
          example: relevant
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Catalog retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - filters
                - counts
                - meta
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: A course or path browse tile. `type` selects which
                        fields are present.
                      required:
                      - id
                      - type
                      - title
                      - delivery_label
                      - enrollment_status
                      - enrolled
                      - cta_action
                      properties:
                        id:
                          type: integer
                          example: 320
                        type:
                          type: string
                          enum:
                          - course
                          - path
                          example: course
                        title:
                          type: string
                          example: Workplace Safety Fundamentals
                        slug:
                          type: string
                          nullable: true
                          example: workplace-safety-fundamentals
                        description:
                          type: string
                          nullable: true
                          description: Plain-text snippet (HTML stripped, truncated
                            to 120 chars — the same rule and length as the web cards).
                          example: Core safety practices for the floor.
                        delivery_label:
                          type: string
                          description: Self-paced / Instructor-Led / Learning Path.
                          enum:
                          - Self-paced
                          - Instructor-Led
                          - Learning Path
                          example: Self-paced
                        delivery_icon:
                          type: string
                          description: Font Awesome glyph for the delivery type —
                            the web banner's type fallback (fa-circle-play / fa-chalkboard-user
                            / fa-route).
                          example: fa-circle-play
                        category:
                          type: object
                          nullable: true
                          description: The subject's TrainingCategory - name, hex
                            color and Font Awesome glyph - for a COURSE and a path
                            alike. Falls back to a legacy free-text course category
                            (name only, null color/icon) when no category record is
                            linked, and is null when the subject has neither.
                          properties:
                            name:
                              type: string
                              example: Safety
                            color:
                              type: string
                              nullable: true
                              example: "#28a745"
                            icon:
                              type: string
                              nullable: true
                              example: fa-shield-alt
                        hero_image_url:
                          type: string
                          nullable: true
                          description: Absolute URL of a resized hero image, or null.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                        formatted_duration:
                          type: string
                          nullable: true
                          example: 1h 30m
                        new:
                          type: boolean
                          description: Published in the last 30 days — the web catalog's
                            New badge and the phone's New chip, from the same rule.
                          example: false
                        enrollment_status:
                          type: string
                          nullable: true
                          enum:
                          - enrolled
                          - in_progress
                          - completed
                          - cancelled
                          - fulfilled
                          description: The caller's raw enrollment status, or null
                            when not enrolled. `cancelled` falls through to an enrol-style
                            cta_action, exactly as if unenrolled.
                          example:
                        enrolled:
                          type: boolean
                          example: false
                        cta_action:
                          type: string
                          enum:
                          - enroll
                          - register
                          - start_path
                          - continue
                          - buy
                          - view
                          - locked
                          description: 'Derived button action (client maps to a label).
                            `locked` means the caller may not act on this item from
                            the catalog — render NO CTA (the same value `TrainingDetailCta.action`
                            carries): a learning path or course with self-enrollment
                            off, an instructor-led course not offering registration,
                            a priced course on a tenant without commerce, or an API
                            token without write:training (the scope every enrol-style
                            POST asks). The set is exactly what `CatalogCardSerializer#cta_action`
                            returns; a closed enum in a generated client fails the
                            whole response on an unknown value, so every value the
                            server can send is listed here.'
                          example: enroll
                        lessons_count:
                          type: integer
                          description: Course cards only.
                          example: 6
                        sessions_count:
                          type: integer
                          description: Instructor-led course cards only — BOOKABLE
                            sessions (scheduled and still upcoming), the same number
                            the course detail reports as sessions_available. Cancelled
                            and past dates are excluded.
                          example: 7
                        rating:
                          type: object
                          description: Course cards only.
                          properties:
                            average:
                              type: number
                              nullable: true
                              example: 4.5
                            count:
                              type: integer
                              example: 12
                        free:
                          type: boolean
                          description: Course cards only.
                          example: true
                        price:
                          type: string
                          description: 'Course cards only — the formatted cost, always
                            present: the price when the course is paid, "Free" when
                            it is not. Branch on `free` for logic; `price` is display
                            text either way.'
                          example: "$49.00"
                        steps_count:
                          type: integer
                          description: Path cards only.
                          example: 3
                        courses_count:
                          type: integer
                          description: Path cards only.
                          example: 8
                  filters:
                    type: object
                    description: The applied filter values (null when not set).
                    properties:
                      q:
                        type: string
                        nullable: true
                        description: The applied search term
                        ? or null. Echoed so a client can badge it and tell "filtered
                          and empty" apart from "my term was ignored" — a crafted
                          non-scalar `q` comes back null.
                        :
                        example:
                      type:
                        type: string
                        nullable: true
                        example:
                      category:
                        type: string
                        nullable: true
                        example:
                      duration:
                        type: string
                        nullable: true
                        enum:
                        - under_30
                        - '30_60'
                        - over_60
                        example:
                      sort:
                        type: string
                        enum:
                        - relevant
                        - newest
                        - shortest
                        - due_date
                        example: relevant
                  counts:
                    type: object
                    description: |-
                      Per-type tab counts over the set narrowed by `q` AND `category`, but NOT by `type` — so tapping a type tab does not change them, while searching or switching category does. Overlap-free: each item is exactly one type.

                      EACH COUNT IS WHAT TAPPING THAT TAB RETURNS. That is why `q` is inside the count and `type` is outside it: `q` and `category` narrow what the learner is looking at, whereas `type` IS the tab that was tapped, and counting after it would leave every tab showing its own count and zero for its siblings. Concretely, with `?q=safety` on a 45-item catalog the counts come back all=5 / self_paced=3 / instructor_led=0 / learning_path=2 — and `?q=safety&type=instructor_led` really does return an empty list, which is what the 0 is telling the client in advance.
                    required:
                    - all
                    - self_paced
                    - instructor_led
                    - learning_path
                    properties:
                      all:
                        type: integer
                        example: 53
                      self_paced:
                        type: integer
                        example: 41
                      instructor_led:
                        type: integer
                        example: 5
                      learning_path:
                        type: integer
                        example: 7
                  meta:
                    type: object
                    description: Pagination over the merged (courses + paths) result
                      (build_pagination_meta).
                    properties:
                      total_count:
                        type: integer
                        example: 53
                      total_pages:
                        type: integer
                        example: 3
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: true
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/catalog/categories":
    get:
      tags:
      - Training
      summary: Catalog category filter options
      description: |
        The category filter options for the Catalog tab — the case-insensitive
        union of course (free-text) and path (TrainingCategory) categories, each
        with a `count` of matching published items and, when a TrainingCategory
        backs the name, its `color` / `icon`. Sorted alphabetically.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - categories
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      required:
                      - name
                      - value
                      - count
                      properties:
                        name:
                          type: string
                          description: Display name.
                          example: Safety
                        value:
                          type: string
                          description: Pass as the catalog `category` param.
                          example: Safety
                        count:
                          type: integer
                          description: Published courses + paths in this category.
                          example: 7
                        color:
                          type: string
                          nullable: true
                          example: "#28a745"
                        icon:
                          type: string
                          nullable: true
                          example: fa-shield-alt
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/my_records/certificates":
    get:
      tags:
      - Training
      summary: My Records — certificates
      description: |
        The learner's certificates, the native mirror of the web My Certificates
        screen (`Apps::Training::Learner::CertificatesController#index`), newest
        issued first and paginated.

        A **Certificate of Completion never expires** — it is permanent proof — so
        `summary.held` is simply the total, and a certificate's `status` reads
        `proof_only` unless it is linked to a certification (via the skill the
        course awarded), which is where any expiry clock lives. The status wording
        comes from `Training::Display`, the same source the web validity chip
        reads, so the chip and the native badge can never disagree.

        SCOPE: this serves `TrainingCertificate` rows only. The web screen also
        merges externally-recorded credentials (`EmployeeSkill`, source
        `external`); every row here carries `source: "training"` so adding those
        later is additive rather than a breaking change.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Certificates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - certificates
                - summary
                - meta
                properties:
                  certificates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - source
                      - certificate_number
                      - title
                      - status
                      properties:
                        id:
                          type: integer
                          example: 64
                        source:
                          type: string
                          enum:
                          - training
                          description: Always "training" today; the discriminator
                            exists for future external credentials.
                          example: training
                        certificate_number:
                          type: string
                          description: Human-quotable id, format CERT-YYYYMMDD-XXXXXX.
                          example: CERT-20260803-BPAWWC
                        title:
                          type: string
                          description: The course or path the certificate was earned
                            for.
                          example: Benefits & Policies
                        issuer:
                          type: string
                          nullable: true
                          description: The issuing business.
                          example: OfficeChat
                        issued_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-08-03T16:38:11Z'
                        issued_at_label:
                          type: string
                          nullable: true
                          description: Pre-formatted issue date, matching the web.
                          example: August 3, 2026
                        credits:
                          type: number
                          nullable: true
                          description: CE credits (frozen snapshot); null when zero
                            so a client renders an em dash.
                          example: 1.5
                        status:
                          type: object
                          description: Validity chip. `proof_only` = a completion
                            certificate with no linked certification, which never
                            expires.
                          required:
                          - key
                          - label
                          - color
                          properties:
                            key:
                              type: string
                              enum:
                              - proof_only
                              - valid
                              - expiring
                              - expired
                              example: proof_only
                            label:
                              type: string
                              description: The chip's word — Active, Expiring Soon,
                                Expired or No Expiry (one vocabulary with the web).
                                The day count is `days_until_expiration`.
                              example: No Expiry
                            color:
                              type: string
                              enum:
                              - secondary
                              - success
                              - warning
                              - danger
                              example: secondary
                            days_until_expiration:
                              type: integer
                              nullable: true
                              description: Present only when the key is `expiring`.
                              example:
                        expires_at:
                          type: string
                          format: date
                          nullable: true
                          description: From the LINKED certification; null for proof-only
                            certificates.
                          example:
                        certification_name:
                          type: string
                          nullable: true
                          description: Name of the linked certification (the skill's
                            name, else the issuing authority).
                          example:
                        subject:
                          type: object
                          nullable: true
                          description: The course or path. Resolved SYSTEM-safely,
                            so a tenant-hidden course still names the row.
                          properties:
                            id:
                              type: integer
                              example: 60
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            title:
                              type: string
                              example: Benefits & Policies
                            delivery_label:
                              type: string
                              enum:
                              - Self-paced
                              - Instructor-Led
                              - Learning Path
                              example: Self-paced
                            category:
                              type: object
                              nullable: true
                              properties:
                                name:
                                  type: string
                                  example: onboarding
                                color:
                                  type: string
                                  nullable: true
                                  example:
                                icon:
                                  type: string
                                  nullable: true
                                  example:
                        enrollment_id:
                          type: integer
                          nullable: true
                          description: The enrollment this certificate came from;
                            matches a transcript entry's id (the detail screen's See
                            this on my transcript link).
                          example: 22750
                        course_version:
                          type: object
                          nullable: true
                          description: The pinned course version, rendered on the
                            detail screen as "v4 · Jul 2026". Courses only — a path
                            pins no version.
                          properties:
                            label:
                              type: string
                              example: v4
                            released_at:
                              type: string
                              format: date-time
                              nullable: true
                              example: '2026-07-01T00:00:00Z'
                        verification_url:
                          type: string
                          nullable: true
                          description: Public, unauthenticated verification page for
                            this certificate number.
                          example: https://app.workforce.mangoapps.com/verify/certificate/CERT-20260803-BPAWWC
                        download_url:
                          type: string
                          nullable: true
                          description: Absolute URL of the certificate PDF; null when
                            the PDF has not been generated yet.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/...
                        thumbnail_url:
                          type: string
                          nullable: true
                          description: Preview image of the PDF's first page (what
                            the web card shows).
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/representations/...
                        can_share_outside:
                          type: boolean
                          description: Whether the learner may share this certificate
                            outside the company (POST /moment_shares, subject_type
                            certificate) — its card and its verification link. False
                            while the company's Shared moments switch is off, or for
                            a token without write:training.
                          example: true
                  summary:
                    type: object
                    description: The header tiles, computed over the learner's WHOLE
                      set (never the current page, which would show different numbers
                      on page 2).
                    required:
                    - held
                    - active
                    - expiring_soon
                    - expired
                    - completions
                    properties:
                      held:
                        type: integer
                        description: Total certificates held (completion proof never
                          expires, so this is the whole count).
                        example: 3
                      active:
                        type: integer
                        description: Certificates whose linked certification has not
                          expired.
                        example: 3
                      expiring_soon:
                        type: integer
                        description: Linked certifications expiring within 30 days.
                        example: 0
                      expired:
                        type: integer
                        description: Linked certifications already expired.
                        example: 0
                      completions:
                        type: integer
                        description: The sibling Transcript tab's badge, so either
                          endpoint can paint the whole tab bar.
                        example: 8
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 3
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/my_records/certificates/{id}":
    get:
      tags:
      - Training
      summary: My Records — one certificate
      description: |
        A single certificate — the Certificate Detail screen. Resolved through the
        same learner-scoped loader as the list, so another learner's (or another
        tenant's) certificate returns 404 rather than leaking.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 64
      responses:
        '200':
          description: Certificate retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - certificate
                properties:
                  certificate:
                    type: object
                    required:
                    - id
                    - source
                    - certificate_number
                    - title
                    - status
                    properties:
                      id:
                        type: integer
                        example: 64
                      source:
                        type: string
                        enum:
                        - training
                        description: Always "training" today; the discriminator exists
                          for future external credentials.
                        example: training
                      certificate_number:
                        type: string
                        description: Human-quotable id, format CERT-YYYYMMDD-XXXXXX.
                        example: CERT-20260803-BPAWWC
                      title:
                        type: string
                        description: The course or path the certificate was earned
                          for.
                        example: Benefits & Policies
                      issuer:
                        type: string
                        nullable: true
                        description: The issuing business.
                        example: OfficeChat
                      issued_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-08-03T16:38:11Z'
                      issued_at_label:
                        type: string
                        nullable: true
                        description: Pre-formatted issue date, matching the web.
                        example: August 3, 2026
                      credits:
                        type: number
                        nullable: true
                        description: CE credits (frozen snapshot); null when zero
                          so a client renders an em dash.
                        example: 1.5
                      status:
                        type: object
                        description: Validity chip. `proof_only` = a completion certificate
                          with no linked certification, which never expires.
                        required:
                        - key
                        - label
                        - color
                        properties:
                          key:
                            type: string
                            enum:
                            - proof_only
                            - valid
                            - expiring
                            - expired
                            example: proof_only
                          label:
                            type: string
                            description: The chip's word — Active, Expiring Soon,
                              Expired or No Expiry (one vocabulary with the web).
                              The day count is `days_until_expiration`.
                            example: No Expiry
                          color:
                            type: string
                            enum:
                            - secondary
                            - success
                            - warning
                            - danger
                            example: secondary
                          days_until_expiration:
                            type: integer
                            nullable: true
                            description: Present only when the key is `expiring`.
                            example:
                      expires_at:
                        type: string
                        format: date
                        nullable: true
                        description: From the LINKED certification; null for proof-only
                          certificates.
                        example:
                      certification_name:
                        type: string
                        nullable: true
                        description: Name of the linked certification (the skill's
                          name, else the issuing authority).
                        example:
                      subject:
                        type: object
                        nullable: true
                        description: The course or path. Resolved SYSTEM-safely, so
                          a tenant-hidden course still names the row.
                        properties:
                          id:
                            type: integer
                            example: 60
                          type:
                            type: string
                            enum:
                            - course
                            - path
                            example: course
                          title:
                            type: string
                            example: Benefits & Policies
                          delivery_label:
                            type: string
                            enum:
                            - Self-paced
                            - Instructor-Led
                            - Learning Path
                            example: Self-paced
                          category:
                            type: object
                            nullable: true
                            properties:
                              name:
                                type: string
                                example: onboarding
                              color:
                                type: string
                                nullable: true
                                example:
                              icon:
                                type: string
                                nullable: true
                                example:
                      enrollment_id:
                        type: integer
                        nullable: true
                        description: The enrollment this certificate came from; matches
                          a transcript entry's id (the detail screen's See this on
                          my transcript link).
                        example: 22750
                      course_version:
                        type: object
                        nullable: true
                        description: The pinned course version, rendered on the detail
                          screen as "v4 · Jul 2026". Courses only — a path pins no
                          version.
                        properties:
                          label:
                            type: string
                            example: v4
                          released_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-07-01T00:00:00Z'
                      verification_url:
                        type: string
                        nullable: true
                        description: Public, unauthenticated verification page for
                          this certificate number.
                        example: https://app.workforce.mangoapps.com/verify/certificate/CERT-20260803-BPAWWC
                      download_url:
                        type: string
                        nullable: true
                        description: Absolute URL of the certificate PDF; null when
                          the PDF has not been generated yet.
                        example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/...
                      thumbnail_url:
                        type: string
                        nullable: true
                        description: Preview image of the PDF's first page (what the
                          web card shows).
                        example: https://acme.workforce.mangoapps.com/rails/active_storage/representations/...
                      can_share_outside:
                        type: boolean
                        description: Whether the learner may share this certificate
                          outside the company (POST /moment_shares, subject_type certificate)
                          — its card and its verification link. False while the company's
                          Shared moments switch is off, or for a token without write:training.
                        example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such certificate for this learner (error code `not_found`).
  "/training/my_records/transcript":
    get:
      tags:
      - Training
      summary: My Records — transcript
      description: |
        The learner's official record of completed training — courses, learning
        paths AND outside completions (a Practice Hub certifiable pass, a course the
        company's other learning system reported done) merged, newest completion
        first — mirroring the web transcript page
        (`Apps::Training::Learner::TranscriptController#index`) and the transcript
        PDF, all three reading one shared loader.

        Rows paginate, but `summary` stays on the FULL filtered set: `total_ceu`
        therefore tracks `?range` and always equals the sum of every row in the
        current view rather than just the page — exactly as the web tiles behave.
        Each row's `credits` is that item's own frozen CE-credit snapshot.

        Path rows carry no `version_label` or `score` (a path pins no version and
        runs no quiz); the web renders an em dash for both.

        An `external` row is an outside completion: its `id` is that record's own
        (a different table from course and path ids), `score` is the score the
        record carries, `credits`, `version_label`, `certificate_id` and `subject`
        are null, and `title`, `provider` and `certificate_url` name it. Course and
        path rows omit those three keys.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: range
        in: query
        description: Limit to the current calendar year. Unrecognised values are treated
          as `all`. Ignored when `year` is given.
        schema:
          type: string
          enum:
          - all
          - year
          default: all
          example: all
      - name: year
        in: query
        description: A specific calendar year (the mobile period picker). Wins over
          `range`; see `available_years` for the options that have records.
        schema:
          type: integer
          example: 2026
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Transcript retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - entries
                - learner
                - range
                - summary
                - meta
                properties:
                  entries:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - type
                      - completed_at
                      properties:
                        id:
                          type: integer
                          description: 'The enrollment id (an `external` row: the
                            outside record''s id).'
                          example: 22750
                        type:
                          type: string
                          enum:
                          - course
                          - path
                          - external
                          example: course
                        title:
                          type: string
                          description: "`external` rows only — the completed training's
                            name."
                          example: Handling an angry customer
                        provider:
                          type: string
                          nullable: true
                          description: "`external` rows only — where it was done (Practice
                            Hub, the other learning system)."
                          example: Practice Hub
                        certificate_url:
                          type: string
                          nullable: true
                          description: "`external` rows only — a web address for the
                            certificate, when the record has one."
                          example:
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-08-03T16:38:11Z'
                        year:
                          type: integer
                          nullable: true
                          description: Completion year — the transcript groups rows
                            under year headings.
                          example: 2026
                        version_label:
                          type: string
                          nullable: true
                          description: Course rows only — the pinned course version.
                          example: v1
                        score:
                          type: number
                          nullable: true
                          description: Course rows only — best quiz score (percent);
                            null when no scored quiz.
                          example:
                        credits:
                          type: number
                          nullable: true
                          description: CE credits for this item; null when zero.
                          example: 1.5
                        certificate_id:
                          type: integer
                          nullable: true
                          description: Fetch via /my_records/certificates/:id; null
                            when the completion issued no certificate.
                          example: 64
                        subject:
                          type: object
                          nullable: true
                          description: Null when the course is gone or hidden — the
                            web prints "Course unavailable" but keeps the completion
                            on the record.
                          properties:
                            id:
                              type: integer
                              example: 60
                            type:
                              type: string
                              enum:
                              - course
                              - path
                              example: course
                            title:
                              type: string
                              example: Benefits & Policies
                            delivery_label:
                              type: string
                              enum:
                              - Self-paced
                              - Instructor-Led
                              - Learning Path
                              example: Self-paced
                            category:
                              type: object
                              nullable: true
                              properties:
                                name:
                                  type: string
                                  example: onboarding
                                color:
                                  type: string
                                  nullable: true
                                  example:
                                icon:
                                  type: string
                                  nullable: true
                                  example:
                  learner:
                    type: object
                    description: Whose official record this is — the transcript header
                      (avatar initials, name, and the "role · location" line).
                    properties:
                      name:
                        type: string
                        example: Marcus Delgado
                      initials:
                        type: string
                        nullable: true
                        example: MD
                      job_title:
                        type: string
                        nullable: true
                        description: This tenant's job title for the learner.
                        example: Parts Counter Associate
                      location:
                        type: string
                        nullable: true
                        example: 'Store #142'
                  range:
                    type: string
                    enum:
                    - all
                    - year
                    description: The all-time / this-year toggle actually applied.
                      Ignored when `year` is given.
                    example: all
                  year:
                    type: integer
                    nullable: true
                    description: The specific period applied via ?year=, or null when
                      unfiltered.
                    example:
                  available_years:
                    type: array
                    description: Years the learner actually completed something in,
                      newest first — the period picker's options.
                    items:
                      type: integer
                      example: 2026
                  download_url:
                    type: string
                    description: |
                      Absolute URL of `GET /training/my_records/transcript/download` with
                      THIS response's `range` / `year` applied, so the PDF a client fetches
                      always matches the rows on screen. Fetch it with the same bearer
                      token; it answers `application/pdf` with `Content-Disposition:
                      attachment`. Never hardcode the path — read it from here.
                    example: https://acme.workforce.mangoapps.com/api/v1/training/my_records/transcript/download?range=year&year=2026
                  summary:
                    type: object
                    description: The web page's three tiles, over the full filtered
                      set.
                    required:
                    - completions
                    - total_ceu
                    - total_ceu_all_time
                    - certificates_held
                    properties:
                      completions:
                        type: integer
                        description: Rows in the current view (tracks the period filter).
                        example: 8
                      total_ceu:
                        type: number
                        description: Sum of every row in the current view (tracks
                          the period filter), rounded to 1dp.
                        example: 3.5
                      total_ceu_all_time:
                        type: number
                        description: Lifetime CEU total, never period-filtered — the
                          figure shown above the period picker.
                        example: 18.5
                      certificates_held:
                        type: integer
                        description: All-time certificate count (not range-filtered).
                        example: 3
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 8
                      total_pages:
                        type: integer
                        example: 1
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/my_records/transcript/download":
    get:
      tags:
      - Training
      summary: My Records — transcript PDF
      description: |
        The learner's transcript as the branded PDF — the document the web's
        "Download PDF" button serves (`Apps::Training::Learner::TranscriptController#download`),
        API-authenticated. Generated per request from the same loader as
        `GET /training/my_records/transcript` with the same `range` / `year`
        filter, so the rows and the "Total credits" line match that payload's
        `entries` and `summary.total_ceu`. Unlike a certificate the transcript is
        not a stored blob, so there is no signed blob URL: call this endpoint,
        ideally via the `download_url` the transcript payload advertises.

        Answers `application/pdf` with `Content-Disposition: attachment;
        filename="transcript-<learner-name>.pdf"` — the attachment disposition
        is what hands the bytes to a native downloader / share sheet. A learner
        with no completions still gets a valid PDF with an empty table, matching
        the web. The caller can only ever obtain their OWN transcript in the
        token's business.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: range
        in: query
        description: Limit to the current calendar year. Unrecognised values are treated
          as `all`. Ignored when `year` is given.
        schema:
          type: string
          enum:
          - all
          - year
          default: all
          example: all
      - name: year
        in: query
        description: A specific calendar year. Wins over `range`; a non-scalar value
          is ignored.
        schema:
          type: integer
          example: 2026
      responses:
        '200':
          description: The transcript PDF
          headers:
            Content-Disposition:
              description: Always `attachment`, with the learner-named filename.
              schema:
                type: string
                example: attachment; filename="transcript-marcus-delgado.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
  "/training/courses/{id}":
    get:
      tags:
      - Training
      summary: Course detail (self-paced or instructor-led)
      description: |
        One course's detail screen, the native mirror of the web learner course page
        (`Apps::Training::Learner::CoursesController#show`). SELF-PACED and
        INSTRUCTOR-LED share this one payload — `delivery_mode` tells the client
        which variant to render, exactly as the web branches on `instructor_led?`:

        * self-paced — a progress block, a `lessons` outline, and a resume CTA;
        * instructor-led — no progress block, a `sessions` list with capacity and
          booking CTAs, the instructors, and an "Attendance: Instructor-marked" info
          row (a constant of the ILT flow, since the instructor marks the roster).

        `tabs` is the web's tab allowlist verbatim: an ILT course shows
        `about / sessions / qa`, an enrolled self-paced course `about / lessons / qa`,
        and a self-paced course you are only browsing shows `about` alone.

        **Two deliberate differences from the web page, both because this is a GET:**

        * **No writes.** The web `#show` auto-enrols the learner when the course sits
          inside an active learning path and calls `start!`, moving them from
          enrolled to in_progress just for opening the page. A read endpoint does
          neither — the client uses the actions that own those transitions.
        * **Prerequisites are reported, not withheld.** The web renders a
          "prerequisites required" page INSTEAD of the course; this returns the
          course with `prerequisites: { met: false, missing: [...] }` so a client can
          render the same blocked screen without a second request.

        `info_rows` and `additional_info` are ORDERED label/value pairs, not fixed
        keys: the "Course Info" card and the custom-field block are both
        tenant-configurable (`additional_info` is every custom field flagged
        show-on-info-page that has a value), so a client renders what it is given
        rather than hardcoding a field list. `info_rows` arrives in the web Course
        Info card's own order — Duration, Lessons, Difficulty, Credits,
        Certificate, Self-enroll — with the two rows the web card has no
        equivalent for (Due, Version) after them.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 320
      responses:
        '200':
          description: Course retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - course
                properties:
                  version_brief:
                    "$ref": "#/components/schemas/TrainingVersionBrief"
                  course:
                    type: object
                    required:
                    - id
                    - type
                    - title
                    - delivery_mode
                    - delivery_label
                    - tabs
                    - cta
                    properties:
                      id:
                        type: integer
                        example: 320
                      type:
                        type: string
                        enum:
                        - course
                        example: course
                      title:
                        type: string
                        example: Workplace Safety Fundamentals
                      slug:
                        type: string
                        nullable: true
                        example: workplace-safety-fundamentals
                      delivery_mode:
                        type: string
                        enum:
                        - self_paced
                        - instructor_led
                        example: self_paced
                      delivery_label:
                        type: string
                        enum:
                        - Self-paced
                        - Instructor-Led
                        example: Self-paced
                      delivery_icon:
                        type: string
                        example: fa-circle-play
                      category:
                        type: object
                        nullable: true
                        description: From the course's TrainingCategory record — the
                          same colour and glyph the web renders. Null only for a course
                          with no category linked.
                        properties:
                          name:
                            type: string
                            example: Health & Safety
                          color:
                            type: string
                            nullable: true
                            example: "#28a745"
                          icon:
                            type: string
                            nullable: true
                            example: fa-shield-alt
                      categories:
                        type: array
                        description: The free-text category split on commas — the
                          hero renders one pill each.
                        items:
                          type: string
                          example: Safety
                      hero_image_url:
                        type: string
                        nullable: true
                        example: https://acme.workforce.mangoapps.com/rails/active_storage/...
                      hero_image_origin:
                        type: string
                        nullable: true
                        enum:
                        - video_frame
                        - source
                        -
                        description: 'Where the hero image came from: video_frame
                          (a still taken from the course''s video), source (the cover
                          its publisher sent), or null (uploaded by a person, or no
                          image). The web shows a non-null origin on cards but gives
                          it way to the category colour band on the detail header.'
                        example: video_frame
                      description:
                        type: string
                        nullable: true
                        description: HTML.
                        example: "<p>The safety baseline every team member needs.</p>"
                      learning_objectives:
                        type: string
                        nullable: true
                        description: HTML — the What you'll be able to do block.
                        example: "<ul><li>Identify hazards</li></ul>"
                      intro_video:
                        type: object
                        nullable: true
                        description: 'Present only when the course has an intro video
                          (embed URL or an attached file). The two are mutually exclusive
                          in practice and reported in SEPARATE fields: `embed_url`
                          goes in an iframe/webview, `file_url` is media bytes for
                          a native player.'
                        properties:
                          embed_url:
                            type: string
                            nullable: true
                            example: https://www.youtube.com/embed/abc123
                          provider:
                            type: string
                            nullable: true
                            example: youtube
                          attached:
                            type: boolean
                            example: false
                          file_url:
                            type: string
                            nullable: true
                            description: Absolute, EXPIRING (1 hour) URL of the uploaded
                              intro video, served inline. Non-null only when `attached`
                              is true; re-fetch the course rather than caching it.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/intro.mp4?disposition=inline
                          content_type:
                            type: string
                            nullable: true
                            description: MIME type of the uploaded file, for picking
                              a player. Non-null only when `attached` is true.
                            example: video/mp4
                      difficulty_level:
                        type: string
                        nullable: true
                        enum:
                        - beginner
                        - intermediate
                        - advanced
                        example: beginner
                      formatted_duration:
                        type: string
                        nullable: true
                        description: Duration of the lesson set in `lessons[]` — ONE
                          version, the caller's pinned version when enrolled and the
                          current published one when browsing. NOT the sum across
                          every version the course has ever had.
                        example: 1h 5m
                      duration_minutes:
                        type: integer
                        nullable: true
                        description: The same figure as `formatted_duration`, in minutes.
                        example: 65
                      lessons_count:
                        type: integer
                        example: 6
                      ce_credits:
                        type: number
                        nullable: true
                        description: null when zero.
                        example: 1.5
                      requires_certificate:
                        type: boolean
                        example: true
                      certificate_validity_days:
                        type: integer
                        nullable: true
                        description: Raw validity window. The screen's Renewal Cycle
                          line comes from a custom field, not from this.
                        example: 365
                      version:
                        type: object
                        nullable: true
                        description: The current published version.
                        properties:
                          label:
                            type: string
                            example: v4
                          released_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-07-01T00:00:00Z'
                      instructors:
                        type: array
                        description: Instructor-led only — the distinct instructors
                          across the UPCOMING sessions (so a course whose sessions
                          have all finished reports `[]`, matching `sessions_available`).
                          Carries the same person shape as the reviews and Q&A payloads,
                          so one avatar component renders all three.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 44
                            name:
                              type: string
                              example: Maria Lopez
                            initials:
                              type: string
                              nullable: true
                              description: For a client that would rather draw its
                                own avatar tile than load one.
                              example: ML
                            job_title:
                              type: string
                              nullable: true
                              example: Regional Safety Trainer
                            avatar_url:
                              type: string
                              description: Absolute. Never null for a live user —
                                falls back to a generated initials tile when no photo
                                is uploaded, so it is safe to render unconditionally.
                              example: https://…/avatar.png
                      rating:
                        type: object
                        description: Approved reviews only.
                        properties:
                          average:
                            type: number
                            nullable: true
                            example: 4.6
                          count:
                            type: integer
                            example: 128
                          distribution:
                            type: object
                            description: Star -> count, for the 5..1 histogram.
                            additionalProperties:
                              type: integer
                            example:
                              '5': 88
                              '4': 27
                              '3': 8
                              '2': 3
                              '1': 2
                          reviews:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TrainingReviewRow"
                      free:
                        type: boolean
                        example: true
                      price:
                        type: string
                        description: Formatted cost, always present — the price when
                          paid, "Free" when not. Branch on `free` for logic.
                        example: Free
                      allow_self_enrollment:
                        type: boolean
                        example: true
                      tabs:
                        type: array
                        description: |-
                          The web tab allowlist: ILT -> about/sessions/qa; enrolled self-paced -> about/lessons/qa; browsing self-paced -> about/lessons (the outline LOCKED, so a learner can judge the course before enrolling).

                          **This also governs the content blocks.** `lessons` and `qa` are null unless `tabs` names them, mirroring the web, which builds the same allowlist server-side and then clamps the requested tab to it — so `?tab=qa` on a self-paced course you are not enrolled in lands back on About. Branch on `tabs`, not on the presence of the blocks.
                        items:
                          type: string
                          enum:
                          - about
                          - lessons
                          - sessions
                          - qa
                        example:
                        - about
                        - lessons
                        - qa
                      info_rows:
                        "$ref": "#/components/schemas/TrainingInfoRows"
                      additional_info:
                        type: array
                        description: Custom fields flagged show-on-info-page that
                          hold a value, in the admin's order. Drives the "Additional
                          information" block (e.g. Regulatory Body, Renewal Cycle,
                          Course Code).
                        items:
                          type: object
                          properties:
                            label:
                              type: string
                              example: Regulatory Body
                            value:
                              type: string
                              example: OSHA 1910
                            field_type:
                              type: string
                              example: text
                      enrollment:
                        type: object
                        nullable: true
                        description: The caller's enrollment, or null when only browsing.
                        properties:
                          id:
                            type: integer
                            example: 4412
                          status:
                            type: string
                            enum:
                            - enrolled
                            - in_progress
                            - completed
                            - cancelled
                            - fulfilled
                            example: in_progress
                          status_key:
                            type: string
                            enum:
                            - not_started
                            - in_progress
                            - completed
                            - registered
                            example: in_progress
                          status_label:
                            type: string
                            description: Display label — the badge word the web and
                              phone course pages print (In Progress / Not Started
                              / Overdue / Completed / Registered / Cancelled). An
                              open enrollment past its due date reads Overdue here
                              while status_key keeps its own enum (not_started or
                              in_progress).
                            example: In Progress
                          progress_percentage:
                            type: integer
                            example: 50
                          completed_lessons_count:
                            type: integer
                            example: 3
                            description: Completed lessons of `total_lessons`.
                          total_lessons:
                            type: integer
                            example: 5
                            description: The lessons `progress_percentage` is computed
                              from — required and playable (every playable lesson
                              on a course with no required lesson); `lessons_count`
                              is the whole outline. A completed enrollment reports
                              `completed_lessons_count` equal to it.
                          required_lessons_only:
                            type: boolean
                            example: true
                            description: True when `total_lessons` leaves an optional
                              or unplayable lesson out.
                          formatted_time_left:
                            type: string
                            nullable: true
                            example: 45m
                          formatted_time_spent:
                            type: string
                            nullable: true
                            example: 20m
                          due_date:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-08-15T00:00:00Z'
                          overdue:
                            type: boolean
                            example: false
                          days_overdue:
                            type: integer
                            nullable: true
                            example:
                          assigned:
                            type: boolean
                            description: The enrollment was given to the learner rather
                              than self-chosen.
                            example: true
                          completed_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          certificate_id:
                            type: integer
                            nullable: true
                            example:
                      course_paths:
                        type: array
                        description: The learning paths this course counts toward
                          FOR THE CALLER — the web page's "Assigned via {path} (+N
                          more)" pill, as an array so a course in two paths is represented
                          honestly. Strictly the caller's OWN active (enrolled / in_progress)
                          path enrollments that include this course; a path the caller
                          is not on, or has completed or left, never appears, and
                          it is never "every path that lists the course". `[]` for
                          a course reached directly. `id` is the PATH's, so each entry
                          opens through `GET /training/learning_paths/{id}`.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          properties:
                            id:
                              type: integer
                              example: 88
                            title:
                              type: string
                              example: New Hire Safety Onboarding
                      prerequisites:
                        type: object
                        description: The web BLOCKS the page when required prerequisites
                          are unmet; this reports the state instead.
                        required:
                        - met
                        - missing
                        properties:
                          met:
                            type: boolean
                            example: true
                          missing:
                            type: array
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 88
                                title:
                                  type: string
                                  example: Safety Orientation
                      lessons:
                        type: array
                        nullable: true
                        description: |-
                          The outline the caller may see: an enrolled learner gets the version they are PINNED to; a caller who is not enrolled gets the currently published version. The same rows the Lessons tab endpoint returns.

                          **A caller who is not enrolled gets the outline LOCKED** — every row `locked: true`, `current: false`, `completed: false` and `web_view_url: null`: titles, types and durations, nothing that opens a lesson, as the web Lessons tab and the `/m/` course page show it. Lesson bodies stay enrollment-gated (`GET .../lessons/{lesson_id}/content` answers 422 `not_enrolled`).

                          **NULL unless `tabs` includes `lessons`** — i.e. null for every instructor-led course (which has a Sessions tab instead). Null means WITHHELD, not empty.
                        items:
                          "$ref": "#/components/schemas/TrainingLessonRow"
                      sessions_available:
                        type: integer
                        nullable: true
                        description: Instructor-led only — count of upcoming scheduled
                          sessions.
                        example: 7
                      sessions:
                        type: array
                        nullable: true
                        description: Instructor-led only.
                        items:
                          "$ref": "#/components/schemas/TrainingSessionRow"
                      my_registration:
                        "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                      class_checklist:
                        type: object
                        nullable: true
                        description: 'Instructor-led only: the course''s sign-off
                          checklist card, as the web About tab draws it — only the
                          caller''s own attempt, and counts only (the items and their
                          signers are the signers'' `/training/checklists` endpoints).
                          Null when the course has no checklist, on a self-paced course,
                          and for a finished attempt that was never asked for one.'
                        required:
                        - lesson_id
                        - title
                        - passing
                        - total
                        - state
                        - state_label
                        properties:
                          lesson_id:
                            type: integer
                            description: The checklist lesson — the edition the caller
                              is being signed on.
                            example: 812
                          title:
                            type: string
                            example: Sign-off checklist
                          passing:
                            type: integer
                            nullable: true
                            description: Items holding a current Met or N/A. Null
                              until the caller's attempt holds the checklist.
                            example: 3
                          total:
                            type: integer
                            nullable: true
                            description: Items on the checklist. Null until the caller's
                              attempt holds the checklist.
                            example: 5
                          state:
                            type: string
                            enum:
                            - browsing
                            - both
                            - attended
                            - signed
                            - done
                            description: "`browsing` not on the course yet; `both`
                              waiting on attendance and signatures; `attended` attended,
                              waiting on signatures; `signed` every item signed, waiting
                              on attendance; `done` the course is complete."
                          state_label:
                            type: string
                            description: The sentence the web card prints for `state`.
                            example: You attended. The course completes when every
                              item is signed.
                      qa:
                        type: object
                        nullable: true
                        description: The Q&A tab's badge counts. **NULL unless `tabs`
                          includes `qa`** — i.e. null for a self-paced course the
                          caller is not enrolled in. Always present for an instructor-led
                          course, matching the web, which offers Q&A on every ILT
                          course and only on an ENROLLED self-paced one.
                        properties:
                          questions_count:
                            type: integer
                            example: 3
                          unanswered_count:
                            type: integer
                            example: 1
                      can_review:
                        type: boolean
                        description: Enrolled and has not already reviewed.
                        example: true
                      can_share:
                        type: boolean
                        description: 'May the caller share this course (POST /shares):
                          anyone who can open it, with a `write:shares` token.'
                        example: true
                      my_review_id:
                        type: integer
                        nullable: true
                        example:
                      cta:
                        "$ref": "#/components/schemas/TrainingDetailCta"
                      secondary_cta:
                        type: object
                        nullable: true
                        description: 'A second action to draw beside `cta`, or null.
                          Today it is set only on a finished self-paced course that
                          issued a certificate and has at least one lesson: `cta`
                          is `view_certificate`, and this is `review`, reopening the
                          course from its first lesson (`lesson_id`, a row of `lessons[]`)
                          — the web course page''s Review course beside View Certificate.
                          Null on an instructor-led course, while browsing, before
                          the course is finished, and on a finished course with no
                          certificate (there `cta` itself is `review`). A separate
                          field, so a client reading only `cta` is unaffected.'
                        required:
                        - action
                        - lesson_id
                        properties:
                          action:
                            type: string
                            enum:
                            - review
                            example: review
                          lesson_id:
                            type: integer
                            description: The lesson to open — the course's first.
                            example: 130
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller (error code `not_found`).
  "/training/learning_paths/{id}":
    get:
      tags:
      - Training
      summary: Learning-path detail
      description: |
        One learning path's detail screen, the native mirror of the web learner path
        page (`Apps::Training::Learner::LearningPathsController#show`). Same shape as
        a course detail with `type: "path"`, plus the `steps` outline.

        **Progress is computed LIVE** from required work (`required_done` /
        `required_total` over the step weights) and never read from the enrollment's
        cached `progress_percentage`: a member course completed outside the path
        leaves that column stale, which is why the web mobile view recomputes it too.

        A step is `locked` only when the path is `sequential`, the caller is
        enrolled, and the step sits after the first incomplete one — the web's exact
        rule.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 3
      responses:
        '200':
          description: Learning path retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - learning_path
                properties:
                  learning_path:
                    type: object
                    required:
                    - id
                    - type
                    - title
                    - tabs
                    - cta
                    properties:
                      id:
                        type: integer
                        example: 3
                      type:
                        type: string
                        enum:
                        - path
                        example: path
                      title:
                        type: string
                        example: New Hire Safety Onboarding
                      slug:
                        type: string
                        nullable: true
                        example: new-hire-safety
                      delivery_mode:
                        type: string
                        enum:
                        - learning_path
                        example: learning_path
                      delivery_label:
                        type: string
                        enum:
                        - Learning Path
                        example: Learning Path
                      delivery_icon:
                        type: string
                        example: fa-route
                      category:
                        type: object
                        nullable: true
                        properties:
                          name:
                            type: string
                            example: Onboarding
                          color:
                            type: string
                            nullable: true
                            example: "#ffc107"
                          icon:
                            type: string
                            nullable: true
                            example: fa-door-open
                      hero_image_url:
                        type: string
                        nullable: true
                        example:
                      hero_image_origin:
                        type: string
                        nullable: true
                        enum:
                        - video_frame
                        - source
                        -
                        description: 'Where the hero image came from: video_frame
                          (a still taken from the course''s video), source (the cover
                          its publisher sent), or null (uploaded by a person, or no
                          image). The web shows a non-null origin on cards but gives
                          it way to the category colour band on the detail header.'
                        example:
                      description:
                        type: string
                        nullable: true
                        example: "<p>The onboarding path every new floor team member
                          completes.</p>"
                      learning_objectives:
                        type: string
                        nullable: true
                        example:
                      intro_video:
                        type: object
                        nullable: true
                        description: Same shape as the course detail's — see `file_url`
                          there.
                        properties:
                          embed_url:
                            type: string
                            nullable: true
                            example:
                          provider:
                            type: string
                            nullable: true
                            example:
                          attached:
                            type: boolean
                            example: false
                          file_url:
                            type: string
                            nullable: true
                            description: Absolute
                            expiring (1 hour) URL of the uploaded intro video. Non-null only when `attached`.:
                            example:
                          content_type:
                            type: string
                            nullable: true
                            example:
                      difficulty_level:
                        type: string
                        nullable: true
                        enum:
                        - beginner
                        - intermediate
                        - advanced
                        example: beginner
                      formatted_duration:
                        type: string
                        nullable: true
                        example: 6h 20m
                      steps_count:
                        type: integer
                        example: 3
                      courses_count:
                        type: integer
                        example: 8
                      ce_credits:
                        type: number
                        nullable: true
                        example: 6.5
                      requires_certificate:
                        type: boolean
                        example: true
                      sequential:
                        type: boolean
                        description: Steps unlock in order.
                        example: true
                      rating:
                        type: object
                        properties:
                          average:
                            type: number
                            nullable: true
                            example: 4.4
                          count:
                            type: integer
                            example: 61
                          distribution:
                            type: object
                            additionalProperties:
                              type: integer
                            example:
                              '5': 34
                              '4': 18
                          reviews:
                            type: array
                            items:
                              "$ref": "#/components/schemas/TrainingReviewRow"
                      allow_self_enrollment:
                        type: boolean
                        example: true
                      tabs:
                        type: array
                        items:
                          type: string
                          enum:
                          - about
                          - steps
                          - qa
                        example:
                        - about
                        - steps
                        - qa
                      info_rows:
                        "$ref": "#/components/schemas/TrainingInfoRows"
                      additional_info:
                        type: array
                        items:
                          type: object
                          properties:
                            label:
                              type: string
                              example: Path Code
                            value:
                              type: string
                              example: ONB-SAF-01
                            field_type:
                              type: string
                              example: text
                      enrollment:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 50
                          status:
                            type: string
                            enum:
                            - enrolled
                            - in_progress
                            - completed
                            - cancelled
                            example: in_progress
                          status_key:
                            type: string
                            enum:
                            - not_started
                            - in_progress
                            - completed
                            - registered
                            example: in_progress
                          status_label:
                            type: string
                            description: Display label — the badge word the web and
                              phone path pages print (In Progress / Not Started /
                              Overdue / Completed / Cancelled). An open path enrollment
                              past its due date reads Overdue here while status_key
                              keeps its own enum (not_started or in_progress).
                            example: In Progress
                          progress_percentage:
                            type: integer
                            description: LIVE required-work percentage, not the cached
                              column.
                            example: 60
                          required_done:
                            type: integer
                            example: 4
                          required_total:
                            type: integer
                            example: 8
                          steps_complete:
                            type: integer
                            example: 1
                          steps_total:
                            type: integer
                            example: 3
                          due_date:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2026-08-15T00:00:00Z'
                          overdue:
                            type: boolean
                            example: false
                          days_overdue:
                            type: integer
                            nullable: true
                            example:
                          assigned:
                            type: boolean
                            example: true
                          completed_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          certificate_id:
                            type: integer
                            nullable: true
                            example:
                          courses_completed_before_joining:
                            type: integer
                            description: How many of the path's courses count toward
                              this attempt from work the learner finished before joining
                              it (prior completions are credited), which is why a
                              path can read part-way or complete on arrival. 0 for
                              a force-retake attempt.
                            example: 0
                      steps:
                        type: array
                        description: The same rows the Steps tab endpoint returns.
                        items:
                          "$ref": "#/components/schemas/TrainingPathStepRow"
                      qa:
                        type: object
                        properties:
                          questions_count:
                            type: integer
                            example: 3
                          unanswered_count:
                            type: integer
                            example: 0
                      can_review:
                        type: boolean
                        example: false
                      my_review_id:
                        type: integer
                        nullable: true
                        example:
                      cta:
                        "$ref": "#/components/schemas/TrainingDetailCta"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
  "/training/courses/{course_id}/lessons":
    get:
      tags:
      - Training
      summary: Course lessons tab
      description: |
        The course detail's LESSONS TAB on its own — the native mirror of the web
        tab.

        The detail endpoint (`GET /training/courses/{id}`) inlines this outline.
        This exists so a client can open or REFRESH just the
        tab — after finishing a lesson, most obviously — without refetching a detail
        payload that also carries sessions, prerequisites, custom fields, reviews
        and the CTA. The rows are produced by the same serializer, so the two can
        never disagree about a row.

        A CALLER WHO IS NOT ENROLLED gets the outline too, as the detail does: 200
        with `enrolled: false`, every row `locked: true` with `web_view_url: null`
        — titles, types and durations only, so a learner can judge the course
        before enrolling. Lesson BODIES remain enrollment-gated:
        `GET .../lessons/{lesson_id}/content` answers 422 `not_enrolled`.

        Branch on the detail's `tabs` for which tabs to draw: an instructor-led
        course has no Lessons tab even though this endpoint answers for it.

        WHICH OUTLINE YOU GET depends on enrollment: an enrolled learner sees the
        course version their enrollment is PINNED to, so a mid-course republish
        never reshuffles the lessons under them; anyone else sees the currently
        published version.

        NOT PAGINATED, deliberately. Unlike reviews and Q&A, an outline is bounded
        by how the course was authored — tens of rows, not thousands — and the tab
        renders it whole, so paginating would buy a round trip and a scroll
        position to manage for nothing.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      responses:
        '200':
          description: Lesson outline retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    subject:
                      "$ref": "#/components/schemas/TrainingSubjectBlock"
                    enrolled:
                      type: boolean
                      description: Whether the caller holds an enrollment. When false
                        every row is `locked`, none is `current`, and `progress.completed_lessons_count`
                        is 0 — the browsing state.
                      example: true
                    progress:
                      type: object
                      description: The same two numbers the course detail reports,
                        from the same two sources. The detail spells them differently
                        — the total as `course.lessons_count`, and the pair again
                        inside `course.enrollment`, which is absent when the caller
                        is not enrolled. Here they are always present, so a client
                        rendering only the tab never has to special-case the browsing
                        state.
                      properties:
                        completed_lessons_count:
                          type: integer
                          example: 3
                        total_lessons:
                          type: integer
                          example: 8
                          description: The rows of this list — the whole outline the
                            tab heads. The count `progress_percentage` is computed
                            from is the course detail's `enrollment` pair (with `required_lessons_only`).
                    lessons:
                      type: array
                      items:
                        "$ref": "#/components/schemas/TrainingLessonRow"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller (error code `not_found`)
            — unknown id, another tenant's course, or one that is not published.
  "/training/learning_paths/{learning_path_id}/steps":
    get:
      tags:
      - Training
      summary: Learning-path steps tab
      description: |
        The path detail's STEPS TAB on its own — the native mirror of the web tab,
        and the structural twin of the course Lessons tab above.

        The detail endpoint (`GET /training/learning_paths/{id}`) already inlines
        these steps; this is what a client re-fetches after finishing a member
        course, without pulling the whole detail back down. Same serializer as the
        detail, so the two agree by construction.

        READ `sequential` BEFORE RENDERING A PADLOCK: a non-sequential path never
        locks a step, so `locked` must not be inferred from position alone.

        Not paginated — a path has a handful of steps by construction.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Steps retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    subject:
                      "$ref": "#/components/schemas/TrainingSubjectBlock"
                    sequential:
                      type: boolean
                      description: Whether the path's steps unlock in order. What
                        makes `locked` on a step row meaningful.
                      example: true
                    enrolled:
                      type: boolean
                      description: Whether the caller holds the path enrollment. When
                        false nothing is locked and every course CTA is `enroll_required`.
                      example: true
                    progress:
                      type: object
                      description: The live required-work rollup, recomputed from
                        completed COURSES rather than read from the enrollment's cached
                        percentage — which goes stale when a member course is completed
                        outside the path.
                      properties:
                        percentage:
                          type: integer
                          example: 40
                        required_done:
                          type: integer
                          example: 2
                        required_total:
                          type: integer
                          example: 5
                        total_courses:
                          type: integer
                          example: 7
                        steps_complete:
                          type: integer
                          example: 1
                        first_incomplete_index:
                          type: integer
                          nullable: true
                          description: Zero-based; null once every step is complete.
                          example: 1
                    steps:
                      type: array
                      items:
                        "$ref": "#/components/schemas/TrainingPathStepRow"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller (error code `not_found`)
            — unknown id, another tenant's path, or one that is not published.
  "/training/courses/{course_id}/lessons/{lesson_id}/content":
    get:
      tags:
      - Training
      summary: One lesson packaged for offline storage
      description: |
        ONE self-paced lesson as HTML the client can STORE ON THE DEVICE and render
        with no connection, plus everything needed to make that HTML work without
        us.

        Use this to download a lesson, cache a lesson for offline, sync course
        content, take a course offline, or prefetch lessons before going out of
        coverage.

        FOR THE ONLINE CASE YOU DO NOT NEED THIS. Load the row's `web_view_url` in
        a WebView instead — that is the same lesson rendered by the same view, with
        nothing to store and nothing to rewrite.

        WHAT THE CLIENT STILL HAS TO DO, in order:

        1. Download every url in `offline.stylesheets` and rewrite the `<link>`
           hrefs to the local copies. These are the SAME digested files the online
           webview links, so an offline lesson is styled by byte-identical CSS.
        2. Parse `offline.html` for `img[src]` and for `url(...)` inside `style`,
           download each, and rewrite to the local copy. `img` is the only media
           tag the body can contain — the sanitiser strips iframe, video, script
           and object — so there is nothing else in the markup to look for.
        3. Render `offline.document` / `offline.video` with the platform's own
           viewer. They are lesson-level attachments, NOT part of the body, so no
           amount of parsing the html would find them.

        `offline.document.url` AND `offline.video.url` ARE SIGNED AND EXPIRING.
        They are for the download pass only — never store the html still pointing
        at them, or the lesson works today and 404s next week. Re-fetch this
        endpoint to re-issue them.

        CHECK `offline.offline_supported` FIRST. Three kinds of lesson can never
        work offline and say so here rather than leaving each platform to derive
        it: `scorm` and `partner_course` (the runtime is on an external host,
        reached with a per-launch token, reporting progress by webhook) and an
        externally-hosted video (a YouTube/Vimeo embed). For those, fall back to
        `lesson.web_view_url` while online.

        A READ — it asks `read:training`, like every other Training read.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 91
      responses:
        '200':
          description: Lesson content retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    lesson:
                      allOf:
                      - "$ref": "#/components/schemas/TrainingLessonRow"
                      - type: object
                        properties:
                          can_share:
                            type: boolean
                            description: Whether POST /shares takes this lesson from
                              the caller (a text, video or document lesson of the
                              course's current version, a token with `write:shares`)
                              — offer "Share a takeaway" exactly then. Draft the note
                              with POST /shares/suggest_note.
                      description: The same row shape the outline returns, so a client
                        can render the stored lesson's header without keeping a second
                        model. `current` is always false here — it is an outline-relative
                        flag.
                    offline:
                      "$ref": "#/components/schemas/TrainingOfflineLesson"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller, or no such lesson IN
            THAT COURSE (error code `not_found`). A real lesson id under the wrong
            course_id is a 404, not a redirect.
        '422':
          description: "`not_enrolled` — the caller holds no enrollment on the course.
            `prerequisites_not_met` — the course has a required prerequisite the caller
            has not finished; `details.missing_prerequisites` names each `{ id, title
            }`. The web refuses the lesson the same way."
  "/training/courses/{course_id}/lessons/{lesson_id}/progress":
    post:
      tags:
      - Training
      summary: Record progress on one lesson
      description: |
        Record progress on a single lesson — mark it complete, save a video
        position, or add time spent.

        Use this to complete a lesson, mark a lesson done, save my place in a
        video, resume where I left off, or record study time.

        A SCORE CANNOT BE SENT HERE. A learner does not grade themselves, so this
        endpoint ignores `score` even if you include it; the field is read back on
        `TrainingLessonProgress` but is written only by SCORM/xAPI callbacks and
        partner-content sync.

        Writes the SAME `TrainingLessonCompletion` row the web writes
        (`learner/lessons#complete` and `#update_progress`) — no second notion of
        "done", so a learner who finishes on the phone is finished on the web, and
        the course's own progress percentage updates from the same callback.

        TWO RULES, both chosen because an offline client replays writes:

        * COMPLETION IS MONOTONIC. `completed: true` completes; nothing here
          un-completes. A stale queued write from before a reset can never revoke
          a completion the learner earned, and a double-tap or a retried flush is a
          no-op rather than a toggle. `completed_at` is not restamped on a replay.
        * LEGACY VIDEO POSITION TAKES THE MAX. Two devices, or a queue flushed out of
          order, must not rewind the learner: sending 40 after 120 keeps 120. To
          move a learner BACKWARDS deliberately, the client tracks that locally —
          Use the new `resume_position` with `base_revision` and a UUID
          `operation_id` for an exact cursor that can intentionally seek backwards.
          Never replace an unacknowledged operation with a new one; replay it
          first, then save the newer cursor against the acknowledged revision.

        `time_spent` is a DELTA for this sitting, not a total — it is added to what
        is stored. Send it and your figure is authoritative; omit it and the server
        records wall-clock time from first open to completion, which is what the
        web records.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 91
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingLessonProgressInput"
      responses:
        '200':
          description: Progress recorded
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    progress:
                      "$ref": "#/components/schemas/TrainingLessonProgress"
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`); the token lacks `write:training`
            (`insufficient_permissions`); or `enrollment_cancelled` — the caller's
            enrollment in the course was cancelled, so its lessons take no progress
            (the web and the phone refuse the same way, in the same words).
        '404':
          description: No such course visible to this caller, or no such lesson in
            that course (`not_found`).
        '422':
          description: |-
            `not_enrolled` — the caller holds no current enrollment on the course, so there is nothing to record progress against. Enroll first (`POST /training/courses/{id}/enroll`). A COMPLETED enrollment is accepted (an optional lesson may still be marked); a cancelled one is the 403 above.

            `prerequisites_not_met` — the course has a required prerequisite the caller has not finished (an admin can add one after enrolling them); `details.missing_prerequisites` names each `{ id, title }`. The web refuses the lesson the same way.

            `invalid_progress` — a value was rejected by the model (a negative position or time). `progress_failed` — anything else; the write did not happen and may be retried.
  "/training/lessons/progress/sync":
    post:
      tags:
      - Training
      summary: Flush a batch of offline lesson progress
      description: |
        The OFFLINE FLUSH DOOR: a batch of the per-lesson writes above, for a client
        that has been recording progress with no connection.

        Use this to sync offline progress, upload queued progress, flush the
        offline queue, or catch up after reconnecting.

        PER-ITEM RESULTS, NEVER ALL-OR-NOTHING — and a client must read them that
        way. A queue that fails as a unit is a queue that cannot be drained: one
        lesson deleted by an admin, or one course the learner was unenrolled from,
        would block every other item behind it forever. Each entry reports its own
        `ok` plus an `error` code; drop what succeeded, and drop
        `lesson_not_found` / `course_not_found` / `not_enrolled` / `prerequisites_not_met` permanently
        (retrying will never succeed). Only `progress_failed` is worth re-queueing.

        Every item goes through the SAME write as the single-lesson endpoint, so
        the monotonic-completion and max-video-position rules hold identically —
        which is what makes flushing a queue twice safe.

        AT MOST 100 ITEMS per request; more is a `422`, not a truncation. Split the
        queue client-side.

        Requires `write:training`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - items
              properties:
                items:
                  type: array
                  maxItems: 100
                  description: The queued writes, in any order. Must be an array.
                  items:
                    allOf:
                    - type: object
                      required:
                      - course_id
                      - lesson_id
                      properties:
                        course_id:
                          type: integer
                          example: 37
                        lesson_id:
                          type: integer
                          example: 91
                    - "$ref": "#/components/schemas/TrainingLessonProgressInput"
      responses:
        '200':
          description: Batch processed. A 200 does NOT mean every item succeeded —
            read `failed` and the per-item `ok`.
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    synced:
                      type: integer
                      description: How many items were written.
                      example: 4
                    failed:
                      type: integer
                      description: How many were rejected. Non-zero with a 200 is
                        normal.
                      example: 1
                    results:
                      type: array
                      items:
                        type: object
                        properties:
                          index:
                            type: integer
                            description: Position in the `items` array you sent —
                              how to match a result back to a queue entry.
                            example: 2
                          lesson_id:
                            type: integer
                            nullable: true
                            example: 91
                          ok:
                            type: boolean
                            example: false
                          error:
                            type: string
                            nullable: true
                            enum:
                            - invalid_item
                            - course_not_found
                            - lesson_not_found
                            - not_enrolled
                            - enrollment_cancelled
                            - prerequisites_not_met
                            - invalid_progress
                            - progress_failed
                            description: 'Present only when `ok` is false. The first
                              six are PERMANENT — drop the queue entry (`enrollment_cancelled`:
                              the learner''s enrollment was cancelled; `prerequisites_not_met`:
                              the course has a required prerequisite the learner has
                              not finished). `progress_failed` may be retried.'
                            example: not_enrolled
                          progress:
                            allOf:
                            - "$ref": "#/components/schemas/TrainingLessonProgress"
                            description: Present only when `ok` is true.
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '422':
          description: "`invalid_items` — `items` was missing or not an array. `too_many_items`
            — more than 100 entries; `details` carries `limit` and `received`."
  "/training/courses/{course_id}/lessons/{lesson_id}/quiz":
    get:
      tags:
      - Training
      summary: Quiz card (assessment entry point)
      description: |
        Everything the learner sees BEFORE opening an attempt: the quiz's shape
        (question count, passing bar, time limit, points), their attempt tallies
        and remaining pool, their standing result, any attempt still open, and the
        ONE action to offer.

        This is the payload behind the assessment row in a course outline
        ("Safety Assessment · Quiz · 7 questions · Pass 80%") and behind the launch
        screen. Re-read it after submitting to refresh that row without refetching
        the whole course detail.

        **HOW A NATIVE CLIENT REACHES A QUIZ.** The learner does not arrive here
        from a list — they arrive from the lesson WebView, and the seam is a URL you
        intercept:

        1. Open the lesson row's `web_view_url` (the bare `?embed=1` /m/ render).
        2. It shows the quiz card, whose controls are plain GET links to
           `/m/apps/training/courses/{course_id}/lessons/{lesson_id}/quiz/launch`,
           carrying `?intent=` and — where one exists — `&attempt_id=`.
        3. Match that PATH in the WebView, ignoring the query string (`embed=1` and
           `mobile=1` also ride along), cancel the navigation, open your own screen.
        4. **`intent` says which screen to open**, so no probe request is needed. It
           is the same vocabulary as `cta.action` below, so one switch serves both
           doors:

           | `intent` | open | first call |
           |---|---|---|
           | `start` | question 1 | `POST .../quiz/attempts` |
           | `retake` | question 1 | `POST .../quiz/attempts` |
           | `resume` | resume at position | `POST .../quiz/attempts` → `resumed: true` |
           | `view_results` | the score | `GET /quiz_attempts/{attempt_id}/results` |

           `attempt_id` is present exactly for `resume` and `view_results`, which
           address an attempt directly — so those two skip this endpoint entirely.
           `locked` never appears: a blocked card renders prose, not a link.
        5. **Follow `cta.action` as given, including for an expired attempt.** When
           `open_attempt.expired` is true the CTA is `view_results`, and
           `GET /quiz_attempts/{id}/results` serves it: the attempt is graded from
           its saved answers on the way in and the normal results payload comes
           back with `attempt.state: "timed_out"` and `attempt.timed_out: true`. No
           client-side rule and no probe `POST` first. (Posting first still works
           and is what a `resume` CTA does — it replies 409 `attempt_timed_out`
           carrying `details.attempt_id` — so a client already written that way
           needs no change.)
        6. Treat `intent` as a **routing hint, not authority.** It is baked in when
           the card renders, so a card left open long enough can say `resume` for a
           clock that has since run out. Open the screen it names, then let the API's
           answer win — start replies 409 `attempt_timed_out` and you route to
           results instead. Same for "may attempt": don't carry it over from the
           webview, the server refuses on its own with 403 `attempt_blocked`.
        7. Run the attempt, then **reload the WebView when your screen closes** —
           passing writes the lesson completion and moves course progress, so the
           card underneath is stale until you do.

        Not intercepting is supported, not broken: `/quiz/launch` is a real route
        that starts or resumes the attempt and lands the learner in the responsive
        web player. It is a GET precisely so it *can* be intercepted — a POST form
        is not reliably visible to Android's `shouldOverrideUrlLoading`.

        **Address the quiz by its LESSON, not by the quiz id.** A course version
        can carry several quiz lessons, so the lesson id is what says which
        assessment is meant — and resolving through the lesson keeps a learner on a
        re-versioned course pinned to the copy they are actually taking.

        **Render `cta` rather than deriving one.** It resolves the same ladder the
        web launch card uses, so three clients cannot each invent their own:

        | `cta.action`   | meaning                                                        |
        |----------------|----------------------------------------------------------------|
        | `start`        | nothing attempted yet — POST an attempt                         |
        | `resume`       | an attempt is open; `attempt_id` is it                          |
        | `retake`       | graded, and another attempt is allowed                          |
        | `view_results` | graded, and no further attempt is allowed (`reason` says why)   |
        | `locked`       | nothing graded and no attempt allowed (`reason` says why)       |

        `attempts.remaining` is **null, not 0**, when the quiz has no attempt
        limit — an unlimited pool has no remainder, and 0 reads as exhausted.

        `outcome` is the attempt the learner's Pass/Fail VERDICT came from, which
        is not necessarily the most recent one: on a `highest` score policy a
        failing retake leaves the verdict on the earlier pass. Do not infer the
        standing result from the newest attempt.

        `outcome.effective_score` is the score that COUNTS toward completion and
        the transcript, under the quiz's score policy (highest / latest / average /
        first). It can differ from `outcome.score_percentage`.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        description: The QUIZ lesson's id. A non-quiz lesson 404s.
        schema:
          type: integer
          example: 412
      responses:
        '200':
          description: Quiz card retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  enrollment:
                    "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
                  card:
                    "$ref": "#/components/schemas/TrainingQuizCard"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (`access_denied`),
            or the token lacks `read:training` (`insufficient_permissions`, #7183).'
        '404':
          description: "`not_enrolled` — the caller holds no enrollment for this course,
            so the assessment is not yet something they can see. Also `not_found`
            for a course outside the caller's business, an unpublished or archived
            course, a lesson that is not in the learner's pinned version, or a lesson
            that carries no quiz. All four answer alike, so none reveals the others."
        '422':
          description: "`prerequisites_not_met` — the course has a required prerequisite
            the caller has not finished; `details.missing_prerequisites` names each
            `{ id, title }`. Starting an attempt answers the same."
  "/training/courses/{course_id}/lessons/{lesson_id}/quiz/attempts":
    post:
      tags:
      - Training
      summary: Start or resume a quiz attempt
      description: |
        Returns the learner's live attempt, creating one only if there isn't
        already one open — and the full question set with it, so the player needs
        no second request to begin.

        **Safe to retry.** A double tap, or a client retrying a request that timed
        out, cannot mint a second attempt or burn a slot out of the attempt pool:
        an open attempt is RESUMED. `resumed` tells you which happened, so the UI
        can say "resuming" instead of implying a fresh clock. A resumed attempt
        keeps the edition, the question order and the answers it already had.

        **200, not 201**, precisely because the common case is a resume — the
        status code would be a worse signal than `resumed`, which is unambiguous.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 412
      responses:
        '200':
          description: Attempt started or resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  resumed:
                    type: boolean
                    description: True when an attempt was already open and this returned
                      it rather than creating one. Its clock has been running since
                      `attempt.started_at`.
                    example: false
                  attempt:
                    "$ref": "#/components/schemas/TrainingQuizAttempt"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`); the token lacks `write:training`
            (`insufficient_permissions`); or `attempt_blocked` — a well-formed request
            the learner may not make. `error.message` is the specific gate, written
            for a learner: the attempt pool is used up, they have already passed and
            the quiz only allows retakes after a fail, or a cooldown is still running.
            403 rather than 422 because nothing about the request is malformed. `enrollment_cancelled`
            — the caller''s enrollment in the course was cancelled, so its quiz takes
            no attempt.'
        '404':
          description: As for the card — `not_enrolled` or `not_found`.
        '409':
          description: |-
            `attempt_timed_out` — the learner had an attempt still open whose clock had already run out. **This request graded it** from the answers it had saved, so there is nothing to resume. `error.details.attempt_id` names it: send the learner to that attempt's results, then let them choose Retake from there (which the results payload gates for you).

            You can see this coming on the card — `open_attempt.expired` is true — and a client that checks it can label its button "View result" instead of "Resume" and skip this round trip. Do not treat it as an error to retry; retrying returns the same 409 until the learner starts a new attempt.
        '422':
          description: "`no_questions` — the quiz has been published with nothing
            in it, so no edition can be sealed and there is nothing to take. `error.message`
            says so in words meant for the learner; show it rather than a generic
            error, and treat it as an admin problem, not a transient one worth retrying."
  "/training/quiz_attempts/{id}":
    get:
      tags:
      - Training
      summary: The live quiz player
      description: |
        The open attempt and every question to render, in the order frozen onto the
        attempt when it started.

        **The whole set comes at once**, not one question per request. The
        assessment screen pages between questions with no loading state, answers
        autosave in the background, and the set is already frozen — so per-question
        fetching would put a network round trip inside the only interaction the
        screen has, and could not surface a newer question anyway.

        Read the answer-stripping contract at the top of this section before
        rendering `questions`: nothing here identifies a correct answer, and for
        `ranking`, `matching_*` and `hotspot` that has consequences for how you
        display and post each one.

        `questions[].answer` echoes what the learner has already entered, in the
        SAME form you post it — so a resumed attempt rehydrates. For `matching_*`
        that echo is in tokens, not in the ids the server stores internally.

        This endpoint refuses a finished attempt (409) rather than handing back a
        quiz the learner can no longer answer; the results endpoint is its
        counterpart, and each refuses the other's state.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 9014
      responses:
        '200':
          description: Attempt retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  lesson_id:
                    type: integer
                    nullable: true
                    example: 412
                  attempt:
                    "$ref": "#/components/schemas/TrainingQuizAttempt"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (`access_denied`),
            or the token lacks `read:training` (`insufficient_permissions`, #7183).'
        '404':
          description: "`not_found` — no such attempt for this caller."
        '409':
          description: |-
            The attempt is over, so there is no player to render. Two codes, both carrying `details.attempt_id` (send the learner to its results) and `details.block_reason` when a further attempt is refused:

            `attempt_finished` — it was already submitted.

            `time_expired` — the clock had run out. **This request finalized it**, grading whatever was last autosaved, so the attempt is now `timed_out`.
  "/training/admin/quiz_attempts/{id}":
    get:
      tags:
      - Training
      security:
      - BearerAuth: []
      summary: Read one graded quiz attempt (Training admin)
      description: |
        The result of one quiz attempt, anyone's, for a system that follows the
        `training.assessment_failed` webhook event (its `read_path` is this
        endpoint). Scores and counts only — never a question or an answer.
        `GET /training/quiz_attempts/{id}` is the learner's own player and serves
        nobody else.

        **Who:** Training administrators (account administrators, HR
        administrators, Training app administrators). Anyone else gets 403.

        **Required Scopes:** `read:training`
      parameters:
      - name: id
        in: path
        required: true
        description: Quiz attempt ID (`quiz_attempt_id` in the event)
        schema:
          type: integer
      responses:
        '200':
          description: The attempt
          content:
            application/json:
              schema:
                type: object
                required:
                - attempt
                properties:
                  attempt:
                    type: object
                    required:
                    - id
                    - user_id
                    - training_quiz_id
                    - attempt_number
                    - state
                    - failed_attempts
                    properties:
                      id:
                        type: integer
                      user_id:
                        type: integer
                      training_quiz_id:
                        type: integer
                      training_enrollment_id:
                        type: integer
                        nullable: true
                      training_course_id:
                        type: integer
                        nullable: true
                      attempt_number:
                        type: integer
                      state:
                        type: string
                      passed:
                        type: boolean
                        nullable: true
                      score_percentage:
                        type: number
                        nullable: true
                      points_earned:
                        type: number
                        nullable: true
                      total_points:
                        type: number
                        nullable: true
                      submitted_at:
                        type: string
                        format: date-time
                        nullable: true
                      time_taken_seconds:
                        type: integer
                        nullable: true
                      failed_attempts:
                        type: integer
                        description: This person's failed attempts at the quiz in
                          the same enrollment.
                      max_attempts:
                        type: integer
                        nullable: true
        '403':
          description: The token lacks `read:training`, Training is not available
            to the caller, or the caller is not a Training administrator
        '404':
          description: No such attempt in this business
  "/training/courses/{course_id}/quiz_attempts/{attempt_id}/review_recap":
    post:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: An AI recap of the lessons behind a failed attempt
      description: |
        A short reminder from the lessons linked to what the learner missed. It
        reads lesson text only, never the quiz. Offer it only when the results
        endpoint's `review.recap_available` is true — the same rule this call
        refuses on. Requires read:training and write:chat. Same turn contract
        as …/lessons/{lesson_id}/help/{kind}; `kind` is `review`.
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
      - name: attempt_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                enrollment_id:
                  type: integer
                  description: The course attempt the results belong to. When sent
                    and it is no longer the current one, the recap is refused.
      responses:
        '200':
          description: Turn queued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingLessonHelpTurn"
        '403':
          description: Missing token scope, or no Ask AI access (`app_disabled`)
        '422':
          description: "`help_unavailable` — no recap for this attempt: it passed
            or is still open, no missed question links a lesson the learner may open,
            or the Training assistant is off for them (`review.recap_available` is
            false)."
        '429':
          description: Ask AI rate limit or monthly budget reached
  "/training/quiz_attempts/{id}/review_complete":
    post:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Say you went back over what you missed
      description: |
        Records that the learner reviewed a failed attempt, which lifts the
        quiz's require-review gate. Their own finished attempts only. The
        results endpoint's `review` says whether one is asked for
        (`review.required`). Repeating the call is safe: the first time is kept.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The review is recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  attempt_id:
                    type: integer
                    example: 9014
                  review_completed:
                    type: boolean
                    enum:
                    - true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '404':
          description: Attempt not found
  "/training/course_manifests":
    post:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Create a draft course from a manifest (Training admin)
      description: |
        Creates one new, unpublished draft course. It never changes an existing
        course, and sending the same manifest twice creates two drafts unless
        the request carries an `Idempotency-Key` header. When anything is wrong
        nothing is created, and `error.details.problems` lists every problem
        with the field it is about. A course holds at most 200 lessons and
        2,000 questions here; a larger one needs a ZIP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - manifest
              properties:
                manifest:
                  type: object
                  description: The course, as GET /training/course_manifests/guide
                    describes
      responses:
        '201':
          description: The draft course
          content:
            application/json:
              schema:
                type: object
                properties:
                  course:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      published:
                        type: boolean
                      url:
                        type: string
                  warnings:
                    type: array
                    items:
                      type: object
        '403':
          description: Only a Training administrator can create a course this way
        '400':
          description: The body is not valid JSON
        '422':
          description: The body has no `manifest` object, or the manifest has problems.
            Nothing was created
  "/training/course_manifests/check":
    post:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Check a course manifest without keeping anything (Training admin)
      description: |
        Validates the manifest against the schema and every rule, then tries
        the import and rolls it back. Answers 200 whether or not the manifest
        is valid, with every problem and the field it is about.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - manifest
              properties:
                manifest:
                  type: object
                  description: The course, as GET /training/course_manifests/guide
                    describes
      responses:
        '200':
          description: The result of the check
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                  problems:
                    type: array
                    items:
                      type: object
                      properties:
                        path:
                          type: string
                          description: A JSON pointer to the field
                        message:
                          type: string
                  warnings:
                    type: array
                    items:
                      type: object
                      properties:
                        path:
                          type: string
                        message:
                          type: string
                  summary:
                    type: object
        '403':
          description: Only a Training administrator can create a course this way
        '400':
          description: The body is not valid JSON
        '422':
          description: The body has no `manifest` object
  "/training/course_manifests/guide":
    get:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Read how to write a course as JSON (Training admin)
      description: |
        Returns the JSON schema for a course manifest, a worked example with
        every question type a manifest can hold, and the rules the schema
        cannot state.
      responses:
        '200':
          description: The schema, example and guide
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema:
                    type: object
                  example:
                    type: object
                  guide:
                    type: string
                    description: The rules, as Markdown
        '403':
          description: Only a Training administrator can create a course this way
  "/training/managed_courses/{key}":
    parameters:
    - name: key
      in: path
      required: true
      description: The outside source's own name for the course — lower-case letters,
        digits, `.`, `_`, `:` and `-`, starting with a letter or digit, at most 100
        characters (`app-training:sites`). One course per key in a business.
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9._:-]{0,99}$"
    get:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Read a course that is published from outside the app (Training admin)
      description: |
        A course whose lessons are produced outside Training — films with their
        quizzes — and published here again whenever they change. `key` is the
        name the outside source gives the course; it never changes, whatever
        the course is later called in Training.

        Answers what Training holds for that key now: the course, its versions,
        and the lessons the next publish would start from, each with the MD5
        checksum (base64) of its files, so a sender can skip a file that has not
        changed.

        **Who:** Training administrators. Anyone else gets 403.

        **Required Scopes:** `read:training`

        **Credential:** a service account token created by a Training
        administrator, sent with its secret (`X-API-Secret`). It acts as the
        person who created it. A token a person makes for themselves cannot
        hold `read:training` or `write:training`, so it is refused here and on
        the three writes below.
      responses:
        '200':
          description: The course as it stands
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ManagedCourse"
        '403':
          description: The token lacks `read:training`, Training is not available
            to the caller, or the caller is not a Training administrator
        '404':
          description: No course is kept under this key in this business (`not_found`)
        '422':
          description: The key is not a usable key (`invalid_key`)
    put:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Open a course for publishing from outside the app (Training admin)
      description: |
        The first step of a publish. Finds the course kept under `key`, or
        creates it, and makes sure it has a draft version of its own to receive
        lessons. Safe to repeat.

        A new course is created from the fields sent (a `title` is required
        then) and stays unpublished until `POST …/publish`. For a course that
        already exists the fields are ignored here: its title, description,
        category, level and objectives change only when a publish succeeds, so
        nothing a learner sees changes part-way through.

        Refused, with nothing changed, when:
        * another course in the business already has that title and was not created from this source — `title_taken`;
        * someone has a version of the course open as a draft in Training — `draft_in_progress`;
        * an earlier publish stopped part-way, and a lesson someone added by hand has since been edited on the live version — `draft_stale` (discard that draft in Training, then send this again);
        * an administrator unpublished or archived the course — `course_unpublished`, `course_archived`;
        * a lesson of the live version could not be copied into the new draft — `lessons_not_copied`.

        **Who:** Training administrators.

        **Required Scopes:** `write:training`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ManagedCourseFields"
      responses:
        '200':
          description: The course, with its draft open
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ManagedCourse"
        '403':
          description: The token lacks `write:training`, Training is not available
            to the caller, or the caller is not a Training administrator
        '409':
          description: "`title_taken`, `draft_in_progress`, `draft_stale`, `course_unpublished`,
            `course_archived`, `lessons_not_copied`, or `busy` (another publish of
            this course is running — send it again)"
        '422':
          description: "`invalid_key`, or `not_saved` with the reason (a new course
            without a title, for one)"
  "/training/managed_courses/{key}/lessons/{lesson_key}":
    parameters:
    - name: key
      in: path
      required: true
      description: The outside source's own name for the course — lower-case letters,
        digits, `.`, `_`, `:` and `-`, starting with a letter or digit, at most 100
        characters (`app-training:sites`). One course per key in a business.
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9._:-]{0,99}$"
    - name: lesson_key
      in: path
      required: true
      description: The source's own name for the lesson. Same form as the course key.
        A lesson's quiz is kept by Training under `<lesson_key>/quiz`; a source never
        sends that key.
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9._:-]{0,99}$"
    put:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Send one lesson of a course published from outside the app (Training
        admin)
      description: |
        Creates or updates one lesson in the draft that `PUT …/managed_courses/{key}`
        opened, and the quiz that follows it. One lesson per request, with its
        files as multipart beside the lesson's details. Safe to repeat: a file
        whose bytes Training already holds is not stored twice, and may simply
        be left out.

        The lesson is the source's lesson as it is now — a description, a
        chapter list or a quiz that is no longer sent is removed from it.

        * A **video** lesson needs its `video` file the first time, and its
          `duration_seconds` every time. A transcript sent is the transcript
          kept, and the video is not transcribed again; a new video sent
          without one is transcribed by Training like any upload. A new video
          without `captions` drops the captions of the video it replaces.
        * A **document** lesson needs its `document` file the first time.
        * A lesson keeps the kind it was first sent as (`kind_changed`).
        * A quiz whose questions differ from the one the lesson has becomes a
          new quiz, with the attempt limit and other settings of the one it
          replaces; learners still on an earlier version of the course keep the
          questions that match what they watched.

        A video may be up to 200 MB; a document must be under 50 MB. What is
        wrong with the lesson's own details is said before any file is stored.

        **Who:** Training administrators.

        **Required Scopes:** `write:training`
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - lesson
              properties:
                lesson:
                  type: string
                  description: The lesson's details, as a JSON string — see `ManagedCourseLessonInput`.
                video:
                  type: string
                  format: binary
                  description: MP4, WebM or QuickTime. For a `video` lesson.
                captions:
                  type: string
                  format: binary
                  description: WebVTT or SubRip captions for the video.
                document:
                  type: string
                  format: binary
                  description: For a `document` lesson — a PDF, for one.
            encoding:
              lesson:
                contentType: application/json
      responses:
        '200':
          description: The lesson as Training now holds it
          content:
            application/json:
              schema:
                type: object
                required:
                - lesson
                properties:
                  lesson:
                    "$ref": "#/components/schemas/ManagedCourseLesson"
        '403':
          description: The token lacks `write:training`, Training is not available
            to the caller, or the caller is not a Training administrator
        '404':
          description: No course is kept under this key yet — open it first (`not_found`)
        '409':
          description: "`not_open` (the course has no draft — open it first), `kind_changed`,
            `draft_in_progress`, `draft_stale`, `course_unpublished`, `course_archived`,
            or `busy`"
        '422':
          description: "`invalid_lesson`, `invalid_key`, `invalid_kind`, `invalid_quiz`,
            `video_required`, `document_required`, `file_refused` (wrong type or too
            large), or `not_saved` with the reason"
        '503':
          description: File storage could not be reached — send the lesson again (`storage_unavailable`)
  "/training/managed_courses/{key}/publish":
    parameters:
    - name: key
      in: path
      required: true
      description: The outside source's own name for the course — lower-case letters,
        digits, `.`, `_`, `:` and `-`, starting with a letter or digit, at most 100
        characters (`app-training:sites`). One course per key in a business.
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9._:-]{0,99}$"
    post:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Publish a course sent from outside the app (Training admin)
      description: |
        The last step. Puts the draft's lessons in the order given — each
        followed by its quiz — removes the lessons the source no longer names,
        brings the course's own title, description, category, level and
        objectives up to date from the fields sent, and publishes the draft as
        the course's new version.

        When the draft is the same as the live version, nothing is published:
        the draft is discarded and `unchanged` is true. Sending the same
        package twice therefore never adds a version.

        A lesson someone added to the course by hand in Training is kept, after
        the lesson it followed.

        All of it happens together or not at all: when this is refused, the
        live version is as it was.

        With `announce`, people are then told the course is in the catalog —
        the course page's **Tell people**: one in-app notice ("New course:
        <title>", or "Updated course: <title>" for a later version), and an
        email too when `email` is true (people without an address get the
        in-app notice only); with `feed` true, a post on the News Feed as well,
        seen only by the same audience. Only people who can see the course in
        the Training catalog are told, never one who already has it, and nobody
        twice about the same version, so sending the same publish again tells
        nobody new and posts nothing new. Nothing is assigned. The publish
        stands whatever `announcement` answers.

        **Who:** Training administrators.

        **Required Scopes:** `write:training`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
              - "$ref": "#/components/schemas/ManagedCourseFields"
              - type: object
                required:
                - lessons
                properties:
                  lessons:
                    type: array
                    description: The keys of the course's lessons, in order. Every
                      key must have been sent to this draft, or already be in it.
                    items:
                      type: string
                  announce:
                    description: Tell people about the course once it is live. Left
                      out, null or false, nobody is told and no `announcement` is
                      answered; `true` is everyone, in the app only.
                    oneOf:
                    - type: boolean
                    - type: object
                      properties:
                        audience:
                          description: "`everyone` (the default) — everyone who can
                            see the course in the catalog — or the people to tell,
                            of whom only those who can see it are told."
                          oneOf:
                          - type: string
                            enum:
                            - everyone
                          - type: object
                            properties:
                              group_ids:
                                type: array
                                description: Saved groups (notification recipient
                                  groups) in this business.
                                items:
                                  type: integer
                              user_ids:
                                type: array
                                description: People in this business.
                                items:
                                  type: integer
                              criteria:
                                type: array
                                description: 'The audience picker''s criteria, each
                                  adding people: `{"type": "department", "ids": [..]}`,
                                  `{"type": "location", "ids": [..]}`, `{"type": "job_title",
                                  "titles": [..]}`, `{"type": "role", "roles": [..]}`.'
                                items:
                                  type: object
                        email:
                          type: boolean
                          default: false
                          description: Send the notice by email as well as in the
                            app (`"true"` / `"false"` are read the same).
                        notify:
                          type: boolean
                          default: true
                          description: Send the in-app notice. False with `feed` true
                            posts on the feed only.
                        feed:
                          type: boolean
                          default: false
                          description: Post it on the News Feed for the same audience
                            — one post per live version, taken down if the course
                            leaves the catalog. When the company's feed does not take
                            Training posts it is refused with `feed_unavailable` if
                            nothing else was asked; with `notify`, the notice goes
                            and `feed.error` says why there is no post.
                        note:
                          type: string
                          maxLength: 500
                          description: The administrator's own line, on the feed post,
                            in the notice and in the email. Refused with `note_too_long`
                            past 500 characters.
      responses:
        '200':
          description: The course after the publish
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/ManagedCourse"
                - type: object
                  required:
                  - unchanged
                  - course_updated
                  properties:
                    unchanged:
                      type: boolean
                      description: True when nothing differed from the live version
                        — no version was published and the course's own fields were
                        already up to date.
                    course_updated:
                      type: boolean
                      description: True when the course's own title, description,
                        category, level or objectives changed.
                    announcement:
                      type: object
                      description: 'Only when `announce` was sent. `queued: true`
                        with how many people it was sent to (someone who muted New
                        training courses is counted and not told), or `queued: false`
                        with why not: `not_in_catalog` (the course is assign-only,
                        or self-enrollment is off for everyone in Training Settings),
                        `sample_course` (sample data never sends a notice), `no_audience`,
                        `nobody_to_tell` (everyone in the audience was told already
                        or has the course), `no_channel` (`notify` and `feed` both
                        false), `feed_unavailable` (only `feed` asked, and the company''s
                        feed does not take Training posts — with `notify` too, the
                        notice goes and `feed.error` says why there is no post), `no_readers`
                        and `save_failed` (only `feed` asked: nobody in the audience
                        can see the course, or the post could not be saved), `note_too_long`,
                        `not_shown` (a MangoApps course this company does not show),
                        `invalid_announce`, or `announce_failed` (the course is published;
                        use Tell people on the course page).'
                      properties:
                        queued:
                          type: boolean
                        recipients:
                          type: integer
                        notify:
                          type: boolean
                        email:
                          type: boolean
                        audience:
                          type: string
                          enum:
                          - everyone
                          - chosen
                        feed:
                          description: Only when `feed` was asked for. `posted` false
                            with `error` when the notice went and the post did not.
                          nullable: true
                          type: object
                          properties:
                            posted:
                              type: boolean
                            already:
                              type: boolean
                              description: The feed already had its post about this
                                version.
                            feed_id:
                              type: integer
                              nullable: true
                            error:
                              type: string
                              nullable: true
                        error_code:
                          type: string
                        message:
                          type: string
        '403':
          description: The token lacks `write:training`, Training is not available
            to the caller, or the caller is not a Training administrator
        '404':
          description: No course is kept under this key (`not_found`)
        '409':
          description: "`not_open`, `draft_in_progress`, `draft_stale`, `course_unpublished`,
            `course_archived`, `approval_required` (the business asks for approval
            before a course is published — approve it in Training, then send this
            again), or `busy`"
        '422':
          description: "`missing_order` (no `lessons`), `invalid_key` (the order names
            something that is not a lesson's own key — a lesson's quiz follows it
            by itself), `missing_lessons` (a key in the order was never sent), `no_lessons`,
            or `not_saved` with the reason"
  "/training/managed_courses/{key}/cover":
    parameters:
    - name: key
      in: path
      required: true
      description: The outside source's own name for the course — lower-case letters,
        digits, `.`, `_`, `:` and `-`, starting with a letter or digit, at most 100
        characters (`app-training:sites`). One course per key in a business.
      schema:
        type: string
        pattern: "^[a-z0-9][a-z0-9._:-]{0,99}$"
    put:
      tags:
      - Training
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      summary: Set the cover image of a course sent from outside the app (Training
        admin)
      description: |
        The image the course's catalog card and page header show. It is not
        part of a version — learners see it as soon as it is saved — so a
        source sends it after `publish`, not while a draft is held.

        An image a Training administrator chose for the course in Training is
        theirs: it is kept, nothing is refused, and `cover` says `kept`.
        Sending the cover the course already has changes nothing; the course's
        `cover_checksum` is the checksum of the one this source sent last.

        A course with no cover shows a still from its first video instead.

        **Who:** Training administrators.

        **Required Scopes:** `write:training`
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - image
              properties:
                image:
                  type: string
                  format: binary
                  description: PNG, JPEG or WebP, under 10 MB. A 16:9 frame fills
                    the card without cropping.
      responses:
        '200':
          description: The course, and what happened to the cover
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/ManagedCourse"
                - type: object
                  required:
                  - cover
                  properties:
                    cover:
                      type: string
                      enum:
                      - set
                      - kept
                      description: "`set` — the course shows this image; `kept` —
                        an administrator chose the course's image in Training, and
                        it stays."
        '403':
          description: The token lacks `write:training`, Training is not available
            to the caller, or the caller is not a Training administrator
        '404':
          description: No course is kept under this key yet — open it first (`not_found`)
        '409':
          description: Someone else is changing this course right now (`busy`)
        '422':
          description: "`invalid_cover` (no `image` file), `file_refused` (wrong type
            or too large), or `not_saved` with the reason"
        '503':
          description: File storage could not be reached — send it again (`storage_unavailable`)
  "/training/quiz_attempts/{id}/answers":
    patch:
      tags:
      - Training
      summary: Autosave answers on an open attempt
      description: |
        Persist answers-in-progress. Fire this on every question change (debounce
        it when the whole quiz is on one page) so backgrounding the app, losing
        signal or a crash never loses answered work — and so a timed attempt that
        expires is graded from something real.

        Deliberately does **not** score, change the attempt's state, or return the
        questions. Submit remains the authority.

        Send the full `answers` object each time, not a delta: it REPLACES what was
        stored. `position` records where the learner was, so a resume reopens on
        that question.

        Returns the saved tally (`answered_count` / `question_count`) so a client
        can drive an "N of 7 answered" line — including the one on the exit
        confirmation — without counting locally. "Answered" is judged per type: a
        `true_false` of `false` counts, a `matching_*` needs at least one filled
        pair, and a `ranking` always counts because it always has an order.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The attempt id.
        schema:
          type: integer
          example: 9014
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                base_revision:
                  type: integer
                  minimum: 0
                  description: The attempt answers_revision last acknowledged. Stale
                    revisions return 409.
                operation_id:
                  type: string
                  format: uuid
                  description: Stable for the exact full snapshot until acknowledged;
                    reuse with changed content is rejected.
                position:
                  type: integer
                  description: 1-based question index the learner is on.
                  example: 3
                answers:
                  "$ref": "#/components/schemas/TrainingQuizAnswers"
      responses:
        '200':
          description: Answers saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved:
                    type: boolean
                    example: true
                  attempt:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 9014
                      current_position:
                        type: integer
                        nullable: true
                        example: 3
                      answered_count:
                        type: integer
                        example: 2
                      question_count:
                        type: integer
                        example: 7
                      time_remaining_seconds:
                        type: integer
                        nullable: true
                        description: Null on an untimed quiz.
                        example: 742
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`); the token lacks `write:training`
            (`insufficient_permissions`, #7183); or `enrollment_cancelled` — the enrollment
            this attempt belongs to was cancelled, so nothing was saved. The player
            and the results stay readable.'
        '404':
          description: "`not_found` — no such attempt FOR THIS CALLER, or its course
            is no longer visible. Another learner's attempt answers identically."
        '409':
          description: |-
            `attempt_closed` — the attempt has already been submitted or timed out, so nothing was saved and the stored answers are untouched. Stop retrying and take the learner to the results.

            `attempt_timed_out` — this answer arrived after the time limit (plus the grace) ran out; it was not saved, and the attempt has just been graded from the answers saved in time. `error.details.attempt_id` names it — take the learner to its results.
  "/training/quiz_attempts/offline":
    post:
      tags:
      - Training
      summary: Replay a quiz attempt taken offline (untimed quizzes only)
      description: |
        One shot: create AND grade an attempt from answers the learner gave with no
        connection, and return the same result payload `submit` does — score, review
        and the learner's new standing in the course. A pass completes the quiz's
        lesson exactly as the online path does.

        **Untimed quizzes only.** A quiz with `time_limit_minutes` is refused with
        `offline_not_permitted_timed` and nothing is created: the clock runs on the
        server from `started_at` and cannot be honest about minutes that passed on a
        device. Read `quiz.offline_allowed` on the quiz card, or
        `offline.quiz` on the lesson content payload, to know in advance.

        **Idempotent on `client_attempt_id`.** Generate a UUID on the device when the
        learner starts, send it with every flush: a retried request returns the
        original result with `outcome: replayed` and `replayed: true`, and spends no
        second attempt. Nothing about the payload is re-graded on a replay.

        **Entitlement is re-checked at replay.** A learner who is out of attempts,
        already passed under `only_after_fail`, or inside a cooldown gets `403
        attempt_blocked` with the same prose the card shows — and nothing is created,
        so the client shows the refusal and discards its local draft.

        **Key `answers` by the question ids from `offline.quiz.questions`** on the
        lesson content payload (the quiz's sealed edition). Shapes per question type
        are the ones `PATCH /quiz_attempts/{id}/answers` accepts — matching via the
        opaque tokens, ranking as an ordered id list, hotspot as `[{x, y}]`. Mandatory
        questions left unanswered are refused with `unanswered_mandatory` and nothing
        is created.

        `started_at` / `finished_at` are the device's timestamps and become the
        attempt's clock (bounded to the last 30 days, never the future).

        Requires `write:training`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - lesson_id
              - client_attempt_id
              - answers
              properties:
                lesson_id:
                  type: integer
                  example: 1463
                course_id:
                  type: integer
                  description: Optional — derived from the lesson when absent.
                  example: 572
                client_attempt_id:
                  type: string
                  maxLength: 64
                  description: A UUID minted on the device when the learner started.
                  example: 2f4d1c0a-6f4e-4d2e-9b1a-3c9f0e7d5a11
                question_ids:
                  type: array
                  items:
                    type: integer
                  description: 'For a question-pool quiz: the `question_ids` the offline
                    package carried. Honoured when it is a draw the edition could
                    have made (its own questions, every required one, the right number);
                    otherwise the attempt is scored on a draw made at replay.'
                started_at:
                  type: string
                  format: date-time
                  example: '2026-09-07T09:12:00Z'
                finished_at:
                  type: string
                  format: date-time
                  example: '2026-09-07T09:26:40Z'
                answers:
                  type: object
                  additionalProperties: true
                  description: Keyed by question id, in the per-type shapes the answers
                    endpoint accepts.
                  example:
                    '9101': a
                    '9102':
                    - c
                    - a
                    - b
      responses:
        '200':
          description: The attempt was created and graded (or, on a replay, the original
            result)
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome:
                    type: string
                    enum:
                    - submitted
                    - replayed
                  replayed:
                    type: boolean
                    description: True when this client_attempt_id had already been
                      flushed — the original result, nothing consumed.
                  result:
                    "$ref": "#/components/schemas/TrainingQuizResult"
                  course:
                    "$ref": "#/components/schemas/TrainingQuizCourseState"
        '403':
          description: "`attempt_blocked` — attempts used up, already passed, or cooling
            down; nothing created. `enrollment_cancelled` — the caller's enrollment
            in the course was cancelled; nothing created. Or `insufficient_permissions`
            — the token lacks `write:training` (#7183)."
        '404':
          description: Not enrolled, or no quiz on this lesson
        '422':
          description: "`offline_not_permitted_timed` (a timed quiz), `unanswered_mandatory`,
            `invalid_client_attempt_id` or `no_questions`; nothing created"
  "/training/quiz_attempts/{id}/submit":
    post:
      tags:
      - Training
      summary: Submit a quiz attempt for grading
      description: |
        Grade and close the attempt, and return the full result — score, review and
        the learner's new standing in the course — so the results screen needs no
        follow-up request.

        Key the `answers` object by the question ids THIS attempt's player
        returned. See the answer-stripping and edition notes at the top of this
        section; ids from anywhere else will report every question unanswered.

        **Passing completes the quiz's lesson**, which moves course progress and,
        when this was the last outstanding requirement, completes the enrollment.
        The `course` block reports that new state, which is what tells a client
        whether to show a course-completion screen. `course.certificate.status` is
        `pending` right after such a completion because issuing it is asynchronous —
        poll `GET /training/my_records/certificates`; do not read a missing id as
        "no certificate".

        **`outcome` has two values.** `submitted` is the normal path. `timed_out`
        means the clock had already run out: the attempt was graded from the last
        autosave and the payload just sent was ignored. Show "time's up" rather
        than presenting it as a normal submission.

        Requires `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 9014
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                answers:
                  "$ref": "#/components/schemas/TrainingQuizAnswers"
      responses:
        '200':
          description: Attempt graded
          content:
            application/json:
              schema:
                type: object
                properties:
                  outcome:
                    type: string
                    enum:
                    - submitted
                    - timed_out
                  result:
                    "$ref": "#/components/schemas/TrainingQuizResult"
                  course:
                    "$ref": "#/components/schemas/TrainingQuizCourseState"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`); the token lacks `write:training`
            (`insufficient_permissions`, #7183); or `enrollment_cancelled` — the enrollment
            this attempt belongs to was cancelled, so nothing was saved. The player
            and the results stay readable.'
        '404':
          description: "`not_found` — no such attempt for this caller."
        '409':
          description: "`already_submitted` — a double submit, a stale screen, or
            a retry after the attempt was graded. **The answers in this request were
            NOT recorded** and the earlier grade stands. Say so rather than quietly
            showing the old results."
        '422':
          description: "`unanswered_mandatory` — one or more required questions are
            blank. Nothing was graded and the attempt stays open. The submitted answers
            were kept as a draft, so the learner loses nothing. `error.details.unanswered_question_ids`
            names the questions and `error.details.unanswered_count` how many, so
            a client can jump the learner straight to the first one."
  "/training/quiz_attempts/{id}/results":
    get:
      tags:
      - Training
      summary: Results of a graded attempt
      description: |
        The post-submit review: the score banner, the per-question outcome list,
        every graded attempt for the switcher, and whether a retake is still on
        offer.

        Attempt-addressed, so a learner can look back at any of their own graded
        attempts — which is what the `result.attempts.graded` list is for. The
        entry with `current: true` is the one being shown.

        **An expired attempt is graded on the way in.** A timed attempt left open
        past its limit is still `in_progress` with a null `submitted_at` until
        something finalizes it, and this endpoint does: it grades the answers that
        were autosaved, writes the quiz-lesson completion when the score clears the
        bar, and renders the normal payload with `attempt.state: "timed_out"` and
        `attempt.timed_out: true`. So the `view_results` CTA the quiz card emits for
        such an attempt can be followed directly. Repeating the call is safe — the
        attempt is already terminal, so nothing is re-graded or re-stamped.

        **`review` can be null.** Answer reveal is a per-quiz setting frozen with
        the attempt's edition, so an admin turning it off cannot retroactively blank
        a review a learner has already seen, and turning it on cannot reveal answers
        for an attempt taken under the old rule. When it is null,
        `review_hidden_reason` is `answers_not_revealed` — render that, not an empty
        list, which reads as a bug.

        **The score's three counts are reported separately on purpose.** Written
        (`text`) answers are not scored, no staff screen shows them, and they are
        excluded from BOTH sides of the score, so `correct_count` /
        `auto_graded_count` / `pending_review_count` are given rather than a single
        "N of M correct" that would silently count a question nobody grades. Compose
        the sentence from figures that add up, and render a `pending_review` row as
        "Not scored" — never as incorrect, and never as awaiting a review (no
        grading step exists). A quiz of written answers only completes passed with
        `score_percentage: null` — render "Not scored", not "0%".

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 9014
      responses:
        '200':
          description: Results retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  lesson_id:
                    type: integer
                    nullable: true
                    example: 412
                  result:
                    "$ref": "#/components/schemas/TrainingQuizResult"
                  review:
                    "$ref": "#/components/schemas/TrainingQuizMissedReview"
                  course:
                    "$ref": "#/components/schemas/TrainingQuizCourseState"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (`access_denied`),
            or the token lacks `read:training` (`insufficient_permissions`, #7183).'
        '404':
          description: "`not_found` — no such attempt for this caller."
        '409':
          description: "`attempt_not_submitted` — the attempt is still LIVE (its clock
            is running and it has no score yet). Send the learner to `GET /training/quiz_attempts/{id}`
            to go on answering. An open attempt whose time limit has EXPIRED is not
            refused: it is graded from its saved answers and returned as a normal
            200 with `attempt.timed_out: true`."
  "/training/courses/{course_id}/lessons/{lesson_id}/scorm":
    get:
      tags:
      - Training
      summary: SCORM card (courseware launch panel)
      description: |
        Everything the learner sees BEFORE launching a courseware module: what the
        package is, whether it can be played at all, their status and score, where
        they left off, and the ONE control to offer — plus the url that opens the
        real player.

        This is the payload behind the prototype's launch panel for a package
        lesson, and the native mirror of the two web launch cards. Re-read it every
        time your runtime WebView closes: the player writes progress straight onto
        the lesson completion, so the card underneath is stale until you do.

        **THIS ENDPOINT DOES NOT HAND YOU CONTENT TO RENDER, AND CANNOT.** A quiz
        is JSON, so a native screen can take it. A SCORM / xAPI / cmi5 / AICC
        package is a folder of third-party HTML and JavaScript that talks to a
        runtime API on this origin (SCORM API calls, the LRS, AICC HACP) — there is
        no payload that would let an app play it. So:

        1. Draw the card from this payload.
        2. When the learner taps the primary control, open `cta.web_view_url` in a
           WebView. It is the chrome-less full-frame player (`?embed=1` strips the
           header, tab bar and prev/next so your own title bar is the only one).
           **That request is what starts the attempt** — it mints the courseware
           registration and a single-use per-launch session — so do not prefetch
           it, and do not open it to "check" the card.
        3. Draw your own exit affordance. The player also carries a "Return to
           course" control which navigates the WebView to the lesson page
           (`/m/apps/training/courses/{course_id}/lessons/{lesson_id}?embed=1`) —
           match that path to close your screen instead.
        4. Reload the card when your screen closes.

        **`cta.web_view_url` is null whenever there is nothing to open**, so a
        client that renders the button only when it is present can never offer a
        control that lands on a refusal. `cta.reason` says why, and `cta.message`
        is prose written for a learner — show it.

        | `cta.action`  | meaning                                                        |
        |---------------|----------------------------------------------------------------|
        | `launch`      | never opened — open `web_view_url`                              |
        | `resume`      | opened before and unfinished; `resume` says where they were     |
        | `review`      | the module is complete — it re-opens READ-ONLY (see below)      |
        | `processing`  | the package is still importing — no launch yet, check back      |
        | `unavailable` | it cannot be played (`reason` says why)                         |

        **A COMPLETED MODULE IS REVIEW-ONLY, and that is a rule about the record,
        not a label.** The moment the lesson completes, its score and training
        time are frozen: the runtime keeps working (it still bookmarks, still
        shows its own score inside the player) but nothing it reports afterwards
        can move `completion.score` or `completion.time_spent_seconds`. So do not
        present `review` as a retake or offer a "try for a better score" — the
        learner cannot earn one, and a client that implies otherwise is lying to
        them. A genuine second attempt is a course RETAKE, which mints a new
        enrollment attempt and a fresh record.

        `cta.secondary` is the "Start over" that discards the bookmark
        (`POST .../scorm/restart`). It appears **only while the lesson is
        unfinished** and there is something to discard — never on a `review`
        card, for the reason above — and only to a credential holding
        `destructive:training`, the scope the restart asks (a signed-in session
        always does). Both web cards and the player's own menu
        follow the identical rule, so a learner never sees it in one place and
        not another.

        **Render `package.launchable`, never `package.status`.** `status` is the
        LESSON's import state; `launchable` is whether a launch can actually
        produce a player (the package imported AND its files landed, or an AICC
        unit that runs on the provider). They disagree in real data — a tenant
        carrying lessons from before the native engine has them stamped `ready`
        with no package at all — and `launchable` is the gate the launch itself
        applies.

        **There is no attempt pool.** Courseware has no attempt limit anywhere in
        the model: one registration exists per lesson attempt and each launch opens
        a SESSION on it, which is what `resume.launches` counts. Do not render
        "attempt 1 of 3" for a module.

        **Courseware is online-only** (`offline.supported: false`). The runtime is
        server-side, so there is nothing a client can store and play on a train —
        unlike a text, video or document lesson, which
        `GET .../lessons/{lesson_id}/content` packages for offline use.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 81
      - name: lesson_id
        in: path
        required: true
        description: The SCORM lesson's id. A lesson of any other content type 404s
          — a course version can carry several courseware lessons, so the lesson id
          is what says which module is meant.
        schema:
          type: integer
          example: 289
      responses:
        '200':
          description: SCORM card retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  enrollment:
                    "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
                  card:
                    "$ref": "#/components/schemas/TrainingScormCard"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (`access_denied`),
            or the token lacks `read:training` (`insufficient_permissions`, #7183).'
        '404':
          description: "`not_enrolled` — the caller holds no enrollment for this course,
            so its modules are not yet something they can open. Also `not_found` for
            a course outside the caller's business, an unpublished or archived course,
            a lesson that is not in the learner's pinned version, or a lesson that
            is not a SCORM lesson. All of them answer alike, so none reveals the others."
        '422':
          description: "`prerequisites_not_met` — the course has a required prerequisite
            the caller has not finished; `details.missing_prerequisites` names each
            `{ id, title }`."
  "/training/courses/{course_id}/lessons/{lesson_id}/scorm/restart":
    post:
      tags:
      - Training
      summary: Start a courseware module over
      description: |
        Drops this attempt's courseware registration — the bookmark, the suspend
        data, the runtime's score and the session history — so the next launch
        plays from the beginning. The card's `cta.secondary` is what offers it,
        and the desktop card's "Start over" button is the same operation through
        the same service.

        **ONLY WHILE THE LESSON IS UNFINISHED.** Once it is complete this answers
        403 `lesson_completed` and changes nothing. That is not a permission quirk
        — a completed lesson's score and training time are frozen, so a reset
        there would throw away the learner's place and then record nothing from
        the replay. A finished module is review-only; a real do-over is a course
        retake (new attempt, new record) or an admin reset from Courseware
        activity, which is the one door allowed to clear a finished learner.

        **A recorded completion STAYS recorded.** Even on an unfinished lesson
        this resets where the learner is, never what has been recorded: the
        completion row, the course progress it drives and any certificate it
        earned are untouched. Same contract in both web doors.

        **Idempotent.** With nothing to reset it removes nothing and still answers
        200 — `reset` says which happened, so a client retrying a dropped request
        cannot do damage and does not have to guess.

        Returns the refreshed CARD, so redraw from this response rather than firing
        a second GET to discover that Resume has become Launch.

        Requires `destructive:training` — it wipes the module's resume state, a
        delete-level act (#7183, #6721).
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 81
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 289
      responses:
        '200':
          description: Progress reset (or nothing to reset)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reset:
                    type: boolean
                    description: True when a registration was actually dropped. False
                      means there was nothing to reset — the module had never been
                      launched, or a previous call already did it.
                    example: true
                  subject:
                    "$ref": "#/components/schemas/TrainingSubjectBlock"
                  enrollment:
                    "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
                  card:
                    "$ref": "#/components/schemas/TrainingScormCard"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`); the token lacks `destructive:training`
            (`insufficient_permissions`); `lesson_completed` — the module is already
            complete and is review-only, so there is nothing to start over (the card
            shows no `cta.secondary` in this state); or `enrollment_cancelled` — the
            attempt has been cancelled, so there is nothing to record a replay against.
        '404':
          description: As for the card — `not_enrolled` or `not_found`.
        '409':
          description: "`package_processing` / `package_stalled` / `package_unavailable`
            — the module cannot be played, so its resume state is left alone rather
            than wiped for something the learner cannot reopen. The same code the
            card's `cta.reason` carries; redraw the card rather than retrying."
  "/training/courses/{course_id}/lessons/{lesson_id}/practice":
    get:
      tags:
      - Training
      summary: Practice lesson card (Practice Hub roleplay)
      description: |
        Everything a learner sees BEFORE starting the AI roleplay behind a
        `practice` lesson: the scenario's difficulty, pass threshold and estimated
        time, the persona they will talk to, whether the practice can be played at
        all, and their standing on it. The native mirror of the web launch card.

        **Draw the Start control from `can_start`.** It is `false` when the
        learner is not enrolled (`enrolled: false`) or the practice is
        unavailable (`unavailable_reason`). Availability is the SAME question the
        start asks, so a client that honours `can_start` never sees the start's
        422s.

        | `unavailable_reason` | meaning                                                    |
        |----------------------|------------------------------------------------------------|
        | `no_scenario`        | the lesson's scenario no longer exists                     |
        | `unpublished`        | the scenario is no longer published (archived, back in review) |
        | `app_disabled`       | Practice Hub is not enabled for the organization           |
        | `no_persona`         | the scenario has no persona to talk to                     |

        An unavailable practice lesson — for any of the four reasons — is left
        out of the course's required count, so it never strands a learner below
        100%.

        **Access comes from the course.** A learner enrolled in the course can take
        its practice lesson whether or not they are in Practice Hub's own audience
        groups — the web runtime admits them the same way. Only the organization
        having Practice Hub enabled is required (`app_disabled` otherwise).

        `open_attempt_id` is an in-progress attempt to RESUME — starting again
        returns it rather than a new one. Counts (`attempts_count`, `best_score`,
        `last_attempt`) cover only the attempts launched from THIS lesson on the
        learner's current enrollment at the lesson's CURRENT scenario, never
        discarded ones and never their Practice Hub library practice.

        Renders for a learner who is not enrolled too (`enrolled: false`), from the
        course's browse version, so the outline can show the card before enrolment.

        **Required Scopes:** `read:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 773
      - name: lesson_id
        in: path
        required: true
        description: The practice lesson's id. A lesson of any other content type
          404s — a course version can carry several practice lessons, so the lesson
          id says which one is meant.
        schema:
          type: integer
          example: 1349
      responses:
        '200':
          description: Practice card retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingPracticeCard"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Training app is not accessible to the caller (`access_denied`),
            or the token lacks `read:training` (`insufficient_permissions`).
        '404':
          description: "`not_found` — a course outside the caller's business, an unpublished
            or archived course, a lesson not in the learner's version, or a lesson
            that is not a practice lesson. All answer alike."
        '422':
          description: "`prerequisites_not_met` — the course has a required prerequisite
            the caller has not finished; `details.missing_prerequisites` names each
            `{ id, title }`."
  "/training/courses/{course_id}/lessons/{lesson_id}/practice/attempts":
    post:
      tags:
      - Training
      summary: Start or resume a practice roleplay
      description: |
        Starts the roleplay behind a practice lesson — or hands back the learner's
        in-progress attempt for this lesson, so a double tap or a retried request
        never mints a second run. The response is the attempt, with the persona's
        opening line already in `turns` when the scenario has one.

        **201** when an attempt was created, **200** when an open one was resumed;
        `resumed` says the same thing in the body.

        Starting marks the lesson started (course progress shows it in progress)
        and satisfies an open Practice Hub assignment for the scenario. The attempt
        is stamped with the lesson and enrollment, which is what lets a passing
        score complete the lesson. The same service runs the web "Start practice"
        button, so the two can never disagree.

        Typed replies only in v1: spoken replies, live voice and persona voice
        playback stay on the web.

        Access comes from the course enrollment, as on the card: Practice Hub's
        audience groups are not asked; the organization must have Practice Hub.

        **Required Scopes:** `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 773
      - name: lesson_id
        in: path
        required: true
        schema:
          type: integer
          example: 1349
      responses:
        '201':
          description: Attempt created
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingPracticeAttempt"
                - type: object
                  properties:
                    resumed:
                      type: boolean
                      example: false
        '200':
          description: The learner's in-progress attempt, resumed
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingPracticeAttempt"
                - type: object
                  properties:
                    resumed:
                      type: boolean
                      example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '404':
          description: As for the card — `not_found`.
        '422':
          description: |-
            Refused, nothing written. `error.message` is written for the learner.

            `practice_unavailable` — `details.reason` is `no_scenario`, `unpublished` or `app_disabled` (the card's `unavailable_reason`).

            `no_persona` — the scenario has no persona to talk to.

            `not_enrolled` — the caller holds no enrollment for this course.

            `prerequisites_not_met` — as for the card.
        '500':
          description: "`start_failed` — the start could not be written; nothing was
            saved. Safe to retry."
  "/training/practice_attempts/{id}":
    get:
      tags:
      - Training
      summary: A practice attempt — transcript, status and scorecard
      description: |
        The roleplay chat and, once scored, its scorecard. Only the caller's own
        COURSE-launched attempts are served here; another learner's id, a missing
        one, and the caller's own Practice Hub library attempt all 404 alike.

        **Poll this while `processing_status` is `queued` or `scoring` and
        `scoring_stalled` is false** — the same signal the web page's reload timer
        uses. It becomes `null` with `status: "scored"` and a `scorecard` on
        success, or `failed` when the judge gave up; re-POST `complete` to retry.
        `scoring_stalled` turns true when a run has not moved for 20 minutes:
        stop polling and offer "Try scoring again" (`can_complete` is then true),
        as the web pages do.

        `lesson_completed`, `course_status` and `course_progress_percentage` are
        read on every response, so the client can move its progress bar and footer
        the moment a passing score lands without re-reading the outline. Only a
        PASS completes the lesson.

        `turns` is the conversation's tail, oldest first — at most 120 turns;
        `turns_truncated` says when earlier turns exist.

        **Required Scopes:** `read:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: A practice attempt id the start returned (the caller's own, course-launched).
        schema:
          type: integer
          example: 5012
      responses:
        '200':
          description: Attempt retrieved successfully
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingPracticeAttempt"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `read:training`
            (`insufficient_permissions`).
        '404':
          description: "`not_found` — no such course-launched attempt for this caller."
  "/training/practice_attempts/{id}/messages":
    post:
      tags:
      - Training
      summary: Send the learner's reply and get the persona's
      description: |
        The learner's typed reply; the persona's answer is generated inside the
        request (one model call, the same turn the web runtime runs) and both
        turns come back. Nothing is saved when the reply fails, so the learner can
        resend the same text.

        Only an attempt whose `status` is `in_progress` takes a reply.

        **Required Scopes:** `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: A practice attempt id the start returned (the caller's own, course-launched).
        schema:
          type: integer
          example: 5012
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                  description: The learner's reply. Longer than 8,000 characters is
                    cut there.
                  example: Yesterday a forklift nearly hit someone at the blind corner
                    by dock 3.
      responses:
        '200':
          description: Both turns, learner first
          content:
            application/json:
              schema:
                type: object
                properties:
                  turns:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TrainingPracticeTurn"
                  can_reply:
                    type: boolean
                    example: true
                  can_complete:
                    type: boolean
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '404':
          description: "`not_found` — no such course-launched attempt for this caller."
        '409':
          description: "`not_in_progress` — the conversation has ended (completed,
            scored or abandoned); `details.status` says which. Redraw, don't retry.
            `practice_unavailable` (`details.reason: app_disabled`) — Practice Hub
            has been switched off for the organization."
        '422':
          description: "`reply_failed` — the persona's reply could not be generated;
            nothing was saved, try again. `ai_limit_reached` — the organization's
            AI usage policy refused it; `error.message` is the policy's own sentence,
            show it. `blank_message` — nothing to send."
  "/training/practice_attempts/{id}/complete":
    post:
      tags:
      - Training
      summary: End the roleplay and queue scoring
      description: |
        Ends the conversation and queues scoring, which runs on a background job
        (several judge calls — too slow for a request). Answers **202** with the
        attempt (`status: "completed"`, `processing_status: "queued"`); poll
        `GET /training/practice_attempts/{id}` until it is scored.

        A second POST while scoring is in flight queues nothing and answers 202
        again. It is also the **retry** after `processing_status: "failed"`.

        **Idempotent while scoring is in flight** — but a run that has STALLED
        (`scoring_stalled: true`, no progress for 20 minutes) is re-queued.

        On a passing score the lesson is completed and course progress moves, by
        the same writeback the web flow uses. A failing score completes nothing;
        the learner can start another run.

        **Required Scopes:** `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: A practice attempt id the start returned (the caller's own, course-launched).
        schema:
          type: integer
          example: 5012
      responses:
        '202':
          description: Scoring queued (or already running)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingPracticeAttempt"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '404':
          description: "`not_found` — no such course-launched attempt for this caller."
        '409':
          description: "`already_scored` — it is scored; read the scorecard. `not_in_progress`
            — it was discarded and will never be scored. `practice_unavailable` (`details.reason:
            app_disabled`) — Practice Hub has been switched off."
        '422':
          description: "`nothing_to_score` — the learner has not replied yet; there
            is nothing to grade."
  "/training/practice_attempts/{id}/discard":
    post:
      tags:
      - Training
      summary: Discard an in-progress roleplay
      description: |
        Throws away a run that is still in progress (`status` → `abandoned`), so a
        false start is never scored and never resumed — the next start creates a
        new attempt. A finished run cannot be discarded.

        **Required Scopes:** `write:training`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: A practice attempt id the start returned (the caller's own, course-launched).
        schema:
          type: integer
          example: 5012
      responses:
        '200':
          description: Discarded
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingPracticeAttempt"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '404':
          description: "`not_found` — no such course-launched attempt for this caller."
        '409':
          description: "`not_in_progress` — only a run still in progress can be discarded;
            `details.status` is its status."
  "/training/courses/{course_id}/lessons/{lesson_id}/transcript_approval":
    parameters:
    - name: course_id
      in: path
      required: true
      schema:
        type: integer
    - name: lesson_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Training
      summary: Review a video transcript for lesson help
      description: Requires read:training and active course content-manager access
        in this organization.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Source text, digest and permitted actions
          content:
            application/json:
              schema:
                type: object
                properties:
                  lesson_id:
                    type: integer
                  transcript:
                    type: string
                    nullable: true
                  digest:
                    type: string
                  approved:
                    type: boolean
                  can_approve:
                    type: boolean
                  can_withdraw:
                    type: boolean
        '403':
          description: Not an active content manager or missing token scope
        '404':
          description: Course or lesson unavailable
    post:
      tags:
      - Training
      summary: Approve the reviewed transcript as lesson-help evidence
      description: Requires write:training and course content-manager access. The
        digest must match the reviewed transcript and current video source. AI agents
        cannot approve their own evidence.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - digest
              properties:
                digest:
                  type: string
      responses:
        '200':
          description: Approved; returns the same source and permission payload as
            GET
        '403':
          description: Action or scope denied
        '409':
          description: Transcript or video changed; review the current source
    delete:
      tags:
      - Training
      summary: Withdraw transcript approval
      description: Requires destructive:training and active course content-manager
        access. Subsequent lesson help fails closed for this source.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Withdrawn; returns the same source and permission payload as
            GET
        '403':
          description: Action or scope denied
  "/training/courses/{course_id}/lessons/{lesson_id}/help/{kind}":
    parameters:
    - name: course_id
      in: path
      required: true
      schema:
        type: integer
    - name: lesson_id
      in: path
      required: true
      schema:
        type: integer
    - name: kind
      in: path
      required: true
      description: "`explain` (alias `simple`) explains the lesson simply; `example`
        gives one illustrative workplace example."
      schema:
        type: string
        enum:
        - explain
        - simple
        - example
    post:
      tags:
      - Training
      summary: Ask lesson help to explain a lesson or give a work example
      description: 'Requires read:training and write:chat, Ask AI and Training access,
        and a current enrollment in the course whose version holds the lesson. Only
        a text lesson, or a video lesson whose transcript a content manager approved,
        has a source to answer from; a quiz is never answered. The prompt is the server''s
        — a client `message`, `system_context`, `app_slug`, `conversation_id` or attachment
        is ignored — and the turn is read-only: it changes no progress and offers
        no actions. Each call opens its own private thread. Subscribe to `websocket.channel`
        with `websocket.subscription.conversation_id` for the streamed answer, exactly
        as for POST /ask_ai/messages; its citations are the `sources` returned here.'
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                enrollment_id:
                  type: integer
                  description: The course attempt the player opened. When sent and
                    it is no longer the current one, the request is refused with enrollment_changed.
      responses:
        '200':
          description: Turn queued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingLessonHelpTurn"
        '403':
          description: Missing token scope, or `unavailable` (no Training or Ask AI
            access)
        '404':
          description: "`not_found` — not enrolled, or the lesson is not in the course
            version this learner takes"
        '409':
          description: "`enrollment_changed` — the course attempt changed; reopen
            the lesson"
        '422':
          description: "`quiz_lesson`, `no_approved_transcript`, `help_unavailable`
            (no usable source, or the Training assistant is off) or `prerequisites_not_met`"
        '429':
          description: Ask AI rate limit or monthly budget reached (the POST /ask_ai/messages
            refusals)
        '503':
          description: Ask AI unavailable, or paused by a security lockdown
  "/training/courses/{course_id}/lessons/{lesson_id}/help/source":
    parameters:
    - name: course_id
      in: path
      required: true
      schema:
        type: integer
    - name: lesson_id
      in: path
      required: true
      schema:
        type: integer
    - name: digest
      in: query
      required: true
      description: The `digest` of the cited source, from the turn's `sources`.
      schema:
        type: string
    - name: enrollment_id
      in: query
      required: false
      schema:
        type: integer
    get:
      tags:
      - Training
      summary: Check a lesson-help citation before opening it
      description: 'Requires read:training. The native twin of the web citation link:
        says whether the cited source is still the lesson as it is now (its text,
        or its approved transcript, unchanged) and where to open it. When `current`
        is false the answer was written from an older source; ask lesson help again.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Whether the citation is current
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      current:
                        type: boolean
                      course_id:
                        type: integer
                      lesson_id:
                        type: integer
                      anchor:
                        type: string
                        nullable: true
                        description: The lesson player's source anchor (`native-lesson-source`)
                          when current.
                      message:
                        type: string
                        nullable: true
                        description: Why the citation is not current.
        '403':
          description: Missing token scope
          or no Training access:
        '404':
          description: Not enrolled
          or the lesson is not in this learner's course version:
  "/training/courses/{course_id}/resume_recap":
    parameters:
    - name: course_id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Training
      summary: Recap the learner's completed lessons
      description: Requires read:training and write:chat. Summarises up to three of
        the learner's completed text lessons or approved-transcript video lessons
        in their current course version — a partial recap, never proof of mastery
        or a quiz answer. Same turn contract and rules as …/lessons/{lesson_id}/help/{kind};
        `kind` is `recap`.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                enrollment_id:
                  type: integer
      responses:
        '200':
          description: Turn queued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingLessonHelpTurn"
        '403':
          description: Missing token scope
          or no Ask AI access:
        '422':
          description: "`nothing_to_recap` — no completed lesson has a source yet;
            or `help_unavailable`"
        '429':
          description: Ask AI rate limit or monthly budget reached
  "/training/courses/{course_id}/lesson_feedbacks":
    parameters:
    - name: course_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Training
      summary: Private lesson feedback
      description: Course content managers see the course queue; learners see only
        their own feedback. Requires read:training. Managers without course content
        permission cannot read employees' private notes.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: 'needs_response — the web queue''s default, open Unclear and
          Outdated reactions; or one status (new, in_review, answered, updated, closed).
          Omitted: every reaction.'
        schema:
          type: string
      responses:
        '200':
          description: Paginated feedback and token-aware can_respond, can_edit_note
            and can_undo flags.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedbacks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TrainingLessonFeedback"
                  meta:
                    type: object
        '403':
          description: Training access or token scope denied
    post:
      tags:
      - Training
      summary: Leave a private lesson reaction
      description: Requires write:training, a current own enrollment, and a lesson
        in its pinned edition. The operation_id makes retries idempotent. Feedback
        does not mark the lesson complete or create a public course review.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - lesson_id
              - enrollment_id
              - reaction
              - operation_id
              properties:
                lesson_id:
                  type: integer
                enrollment_id:
                  type: integer
                reaction:
                  type: string
                  enum:
                  - useful
                  - unclear
                  - outdated
                operation_id:
                  type: string
                  format: uuid
                note:
                  type: string
                  maxLength: 500
                reason:
                  type: string
                  enum:
                  - need_example
                  - too_fast
                  - hard_to_understand
                  - not_relevant
                  - possibly_wrong
                  - playback_or_access
      responses:
        '201':
          description: Saved reaction
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingLessonFeedback"
        '403':
          description: Training access or token scope denied
        '200':
          description: Idempotent replay of the same reaction
        '409':
          description: Reused operation or enrollment changed
        '404':
          description: Lesson unavailable
        '422':
          description: Invalid reaction
  "/training/courses/{course_id}/lesson_feedbacks/{id}":
    parameters:
    - name: course_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Training
      summary: Read private feedback and its response
      description: Only the reporting employee or an active course content manager.
        Requires read:training.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Feedback with allowed actions
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingLessonFeedback"
        '404':
          description: Not found or not visible
    patch:
      tags:
      - Training
      summary: Add a note or respond to lesson feedback
      description: Requires write:training. The reporting employee can edit a note/reason
        while open. Course content managers can assign, respond, and change status.
        Closing also requires destructive:training. Answered, Updated and Closed require
        a response. Updated additionally requires the same lesson in a newer published
        course version. Replies notify only the reporting employee.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  maxLength: 500
                reason:
                  type: string
                status:
                  type: string
                  enum:
                  - new
                  - in_review
                  - answered
                  - updated
                  - closed
                response:
                  type: string
                  maxLength: 2000
                assignee_id:
                  type: integer
                  nullable: true
                corrected_lesson_id:
                  type: integer
                  nullable: true
      responses:
        '200':
          description: Feedback updated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingLessonFeedback"
        '403':
          description: Action or scope denied
        '422':
          description: Response or published correction required
    delete:
      tags:
      - Training
      summary: Undo my new feedback
      description: Requires destructive:training. Only its author, while the feedback
        is New and unanswered.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Removed
        '403':
          description: Already handled
          not the author:
          or missing scope:
  "/training/courses/{course_id}/reviews":
    get:
      tags:
      - Training
      summary: Course ratings & reviews
      description: |
        The FULL Ratings & Reviews list for a course — the "See all" sheet behind
        the summary block on the course detail. Paginated and sortable.

        The detail endpoint (`GET /training/courses/{id}`) already inlines the
        newest 5 reviews plus the same `rating` summary; this is what the client
        opens for the rest. The summary is repeated because the sheet renders its
        own histogram header.

        APPROVED reviews only — the same filter the web block applies. Ordering:
        `recent` (newest first, the default) and `lowest` (lowest rating first,
        for a learner looking for the caveats before starting).

        There is no `helpful` sort and no `helpful_count` on a row. The Helpful
        affordance is admin-only on the web — its button and count live on the
        admin reviews index, which no learner surface links to — so a
        learner-facing list neither displays that number nor orders by it.
        `sort=helpful` is treated as unrecognised and falls back to `recent`.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: sort
        in: query
        required: false
        description: Unrecognised values fall back to `recent`.
        schema:
          type: string
          enum:
          - recent
          - lowest
          default: recent
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Reviews retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Rate a course
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Post the caller's review of a course — the native mirror of the learner's
        inline "Rate this course" modal on the web About tab.

        **Eligibility** is the same rule the GET reports as `can_review`: the
        caller must be ENROLLED and must not have reviewed this course already.
        A client that honours `can_review` never hits the two 422s below; they
        exist for a stale screen, and they carry DIFFERENT codes because the
        client's next move differs:

        * `already_reviewed` — offer *Edit your review* instead. (A review still
          awaiting moderation counts, so this fires whenever `my_review` is set.)
        * `not_enrolled` — offer *Enroll* instead.

        **One review per learner per course**, enforced by both a model
        validation and a unique index. There is no POST-to-update: re-posting is
        `already_reviewed`.

        **Verified purchase** is stamped server-side when the caller bought the
        course, exactly as on the web — clients neither send nor control it.

        **Moderation**: reviews are approved on creation (the column default), so
        the new review is live and appears in the very next GET of this list. The
        response returns the recomputed `rating` histogram, so a client refreshes
        its summary block without a second call.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewCreateRequest"
      responses:
        '201':
          description: Review posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller (error code `not_found`).
        '422':
          description: |
            Not eligible (`already_reviewed`, `not_enrolled`) — both in the
            standard `{ error: { code, message } }` envelope — **or** the body
            failed validation (a missing/out-of-range `rating`, an over-long
            `title`/`content`), which answers with the platform's per-field
            `{ errors: [{ field, message, code }] }` shape instead.
  "/training/learning_paths/{learning_path_id}/reviews":
    get:
      tags:
      - Training
      summary: Learning-path ratings & reviews
      description: |
        The FULL Ratings & Reviews list for a learning path. Identical payload and
        parameters to the course variant above (`subject.type` is `path`) — reviews
        are polymorphic, so one controller serves both.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      - name: sort
        in: query
        required: false
        description: Unrecognised values fall back to `recent`.
        schema:
          type: string
          enum:
          - recent
          - lowest
          default: recent
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Reviews retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Rate a learning path
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Post the caller's review of a learning path. Identical body, payload and
        error codes to the course variant above — reviews are polymorphic, so one
        controller serves both.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewCreateRequest"
      responses:
        '201':
          description: Review posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
        '422':
          description: |
            Not eligible (`already_reviewed`, `not_enrolled`), or the body failed
            validation — see the course variant above for both envelopes.
  "/training/courses/{course_id}/questions":
    get:
      tags:
      - Training
      summary: Course Q&A
      description: |
        The Course Q&A screen — question threads with their answers, the native
        mirror of the Q&A tab on the web course page.

        Each thread carries its answers ordered best-answer-first then
        most-upvoted (the web's exact sort), plus `best_answer` as a pointer into
        that array for clients showing a single answer.

        `filter` is the tab's three pills: `all` (newest first, the default),
        `unanswered`, and `top` (most upvoted). `counts` is filter-INDEPENDENT so
        one request badges all three.

        The detail endpoint's `qa: { questions_count, unanswered_count }` block is
        the tab badge; this is the tab's contents.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: filter
        in: query
        required: false
        description: Unrecognised values fall back to `all`.
        schema:
          type: string
          enum:
          - all
          - unanswered
          - top
          default: all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Q&A retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQuestionsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Ask a question, or reply to one
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        **ONE endpoint for both halves of a Q&A thread**, because they are one
        composer to the client:

        * omit `question_id` → ASK a new question on this course
        * pass  `question_id` → REPLY (post an answer) to that question

        Mirrors the web's two actions (ask + answer) on the same screen.

        **Threading is one level**, structurally: an answer has no parent answer,
        so there is no reply-to-a-reply case. `question_id` is resolved through
        THIS course's own thread — an id belonging to another course, another
        learning path or another tenant is 422 `question_not_found`, never a
        misfiled answer.

        **No per-owner permission**: any Training user in the business may ask and
        answer (`permissions.can_ask` / `can_answer` are true for anyone holding a
        200 on the GET). Only marking a best answer is gated — see
        `POST /training/qa/answers/{id}/mark_best`.

        **No notifications** are sent, matching the web: an instructor finds
        unanswered questions from the tab's own `counts.unanswered` badge.

        The response is the WHOLE updated thread, not just the row written, so one
        response re-renders the card — posting an answer also flips the question's
        `status` to `answered` and re-sorts `answers`. `answer_id` names the row
        just created (null when a question was asked) so a client can highlight it
        without diffing.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingQaPostRequest"
      responses:
        '201':
          description: Question asked, or answer posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQaThread"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such course visible to this caller (error code `not_found`).
        '422':
          description: |
            `body_required` (nothing but whitespace was sent) or
            `question_not_found` (`question_id` isn't a question on this course),
            both in the standard `{ error: { code, message } }` envelope — or a
            model validation failure (body over its length cap) in the per-field
            `{ errors: [...] }` shape.
  "/training/learning_paths/{learning_path_id}/questions":
    get:
      tags:
      - Training
      summary: Learning-path Q&A
      description: |
        The Q&A thread list for a learning path. Identical payload and parameters
        to the course variant above (`subject.type` is `path`) — Q&A is
        polymorphic, so one controller serves both.

        One difference in the DATA, not the shape: `author.instructor` is always
        false here. The Instructor role is per-COURSE; a learning path has no
        equivalent, which is why the web badge never appears on a path thread
        either.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      - name: filter
        in: query
        required: false
        description: Unrecognised values fall back to `all`.
        schema:
          type: string
          enum:
          - all
          - unanswered
          - top
          default: all
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Clamped to 1..50; anything unparseable falls back to the default.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Q&A retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQuestionsPage"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
    post:
      tags:
      - Training
      summary: Ask a question on a learning path, or reply to one
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Identical body, payload and error codes to the course variant above — Q&A
        is polymorphic, so one controller serves both. Omit `question_id` to ask,
        pass it to reply.

        One difference in the DATA, not the shape: `permissions.can_mark_best` is
        true for a Training admin or the PATH'S CREATOR here, because the
        Instructor role is per-COURSE and a path has no equivalent (the same
        branch the web tab takes).
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingQaPostRequest"
      responses:
        '201':
          description: Question asked, or answer posted
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQaThread"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller (error code `not_found`).
        '422':
          description: |
            `body_required` or `question_not_found`, or a model validation
            failure — see the course variant above for both envelopes.
  "/training/qa/{votable_type}/{id}/vote":
    post:
      tags:
      - Training
      summary: Toggle an upvote on a question or an answer
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        **TOGGLE** the caller's upvote ("helpful") on a question OR an answer —
        the native mirror of the web's two upvote buttons, which are the same
        toggle on the same polymorphic vote record.

        ONE endpoint, with `votable_type` selecting the target, for the same
        reason one controller serves both owner types on the read side: the vote
        is polymorphic, and the web's two actions differ only in which row they
        load.

        **A toggle, not a POST/DELETE pair** (unlike the Ideas vote API): a
        Training upvote un-votes on a second press on every surface, and the tap a
        client is mirroring doesn't know which direction it is going. So this is
        **NOT idempotent** — two calls return to the starting state — and the
        response always reports the RESULTING state read back from the database.
        Patch your cached row from `votes_count` / `my_vote`; never predict them.

        No per-owner permission — upvoting is open to any Training user in the
        business, exactly as on the web.
      security:
      - BearerAuth: []
      parameters:
      - name: votable_type
        in: path
        required: true
        description: Which kind of row to toggle. Anything other than these two words
          does not match the route and 404s at the router.
        schema:
          type: string
          enum:
          - questions
          - answers
      - name: id
        in: path
        required: true
        description: The question's or answer's own id (not the course's).
        schema:
          type: integer
          example: 30
      responses:
        '200':
          description: Upvote toggled; the resulting state is returned.
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  required:
                  - votable
                  properties:
                    votable:
                      "$ref": "#/components/schemas/TrainingQaVoteState"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'The Training app is not accessible to the caller (error code
            `access_denied`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such question/answer in the caller's business, or its course
            / learning path is no longer visible to the caller (error code `not_found`
            for both — neither answer reveals the other).
  "/training/qa/answers/{id}/mark_best":
    post:
      tags:
      - Training
      summary: Mark an answer as the best answer
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Mark one answer as its question's BEST answer — the native mirror of the
        web Mark-best affordance. Clears whichever answer held the flag before and
        flips the question to `answered`, in one transaction.

        **The one Q&A action with a per-owner permission**: the owner's content
        manager only — a Training admin, the COURSE's Instructor, or (for a
        learning path, which has no per-path role) the path's creator. Learners
        may ask, answer and upvote; deciding which answer is authoritative is
        moderation.

        The Q&A GET reports the same decision as `permissions.can_mark_best`, so
        render the affordance from that flag rather than from the existence of
        this endpoint — otherwise every learner sees a button that 403s on tap.

        **Not a toggle**: there is no un-mark on any surface. Re-marking the answer
        that already holds the flag is a successful no-op; marking a DIFFERENT
        answer moves the flag, which is how a mistake is corrected.

        Answers with the updated thread — the same payload the Q&A POST returns —
        because marking a best answer re-sorts `answers` (best first) and can
        change the question's `status`, so a narrower response would leave the
        card wrong.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The answer's own id.
        schema:
          type: integer
          example: 23
      responses:
        '200':
          description: Best answer set; the updated thread is returned.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingQaThread"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |-
            The Training app is not accessible to the caller (`access_denied`), or the caller is not a content manager of the answer's course / learning path (`forbidden`).
            `insufficient_permissions` — the token lacks `write:training` (#7183).
        '404':
          description: No such answer in the caller's business, or its course / learning
            path is no longer visible to the caller (error code `not_found`).
        '422':
          description: "`mark_best_failed` — two managers marked a best answer on
            the same question at the same moment and the unique index rejected this
            one. Retrying succeeds; re-offer the action."
  "/training/my_team":
    get:
      tags:
      - Training
      summary: My Team dashboard (one tab per request)
      description: |
        The manager surface: the four stat tiles, the three pill counts, and **one
        page of the active tab**.

        **Fetch the tab you are showing.** `tab` selects which list comes back —
        `people` (the roster, the default), `overdue`, or `done` — and `page` /
        `per_page` paginate **that** list, with `meta` describing it. An
        unrecognised `tab` falls back to `people`, and `active_tab` echoes what was
        actually applied.

        **Named keys, one populated.** `members`, `overdue` and `completions`
        always all appear; only the active tab's carries rows. That keeps the
        element type of each key stable for a generated client, which a single
        polymorphic `rows` key would not. Read `active_tab` to know which to use.

        **`stats` and `pills` are TAB-INDEPENDENT**, so one request still badges
        all three pills and switching tabs needs no second count.

        **SCOPE: the role sets the default AND the ceiling.** An admin/HR
        admin/Training app admin defaults to every active member of the business;
        a people-manager sees their direct reports. Someone whose reports have
        reports may also ask for `whole_line` — their direct reports and everyone
        below them — which is never a default. `scope` reports what was actually
        applied:

        - `scope.key` — `all_employees`, `direct_reports` or `whole_line`, the
          scope in force.
        - `scope.available` — what THIS caller may ask for: `direct_reports` and
          `all_employees` for an admin, `["direct_reports"]` alone for a manager,
          plus `whole_line` for anyone with a reporting line below their reports,
          so a client renders the segmented control only when there is a real
          choice.
        - `scope.admin_view` — whether these rows are the whole business. Derived
          from the APPLIED scope, not from the role, so an admin who has narrowed
          to their reports gets the "Team" noun rather than "Employees".

        Pass `scope` to switch. An admin may narrow to `direct_reports`; a manager
        asking for `all_employees` is served their reports regardless — the role is
        a ceiling, not a suggestion — and `scope.key` echoes what was applied.
        Unrecognised values fall back to the default, the same convention `tab`
        uses.

        The drill-in (`/training/my_team/{id}`) deliberately IGNORES `scope`: it
        resolves through the caller's entitled scope, so narrowing the dashboard
        never turns another employee's row into a 404.

        `scope.total` carries the true headcount beside the page — what a
        "Showing 8 of 142" caption needs. `stats.team_size` is that same total.
        `pills[].key` values are stable and match the `tab` values.

        The `overdue` and `done` lists span the WHOLE team, not the roster page,
        because their pill counts the team — a list that disagreed with its own
        badge would be worse than a slow one.

        > **Why one tab per request.** An earlier revision returned all three
        > lists every time and fed each of them the whole team's ids. On a
        > 41,260-person tenant that measured 8.1 s, 157 queries and 2.5 MB of SQL
        > text — eight statements carrying a 41,262-element `IN (...)` list — to
        > produce a 15 KB payload. Per-tab fetching plus subquery filtering brings
        > the same request to ~110 ms, and nothing on the path scales with
        > headcount.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: tab
        in: query
        required: false
        description: Which list to return. Matches the `pills[].key` values. Unrecognised
          values fall back to `people`.
        schema:
          type: string
          enum:
          - people
          - overdue
          - done
          default: people
      - name: scope
        in: query
        required: false
        description: Which team to report on. `all_employees` is admin-only and is
          an admin's default; `direct_reports` is a manager's only option and their
          default. `whole_line` — direct reports and everyone below them — is offered
          only to someone whose reports have reports. A manager passing `all_employees`
          is still served their direct reports (the role is a ceiling), and so is
          a caller passing `whole_line` with no line below their reports. Unrecognised
          values fall back to the caller's default. `scope.key` in the response echoes
          what was applied, and `scope.available` lists what this caller may ask for.
        schema:
          type: string
          enum:
          - direct_reports
          - all_employees
          - whole_line
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Page size for the ACTIVE tab. Clamped to 1..50; anything unparseable
          falls back to 20.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      - name: brief_kind
        in: query
        required: false
        description: One kind of the manager's action brief (`actions`) to page through
          instead of its top five. Unrecognised values are ignored.
        schema:
          type: string
          enum:
          - signoffs
          - follow_up
          - renewals
          - overdue
      - name: brief_page
        in: query
        required: false
        description: With `brief_kind`, which 20-row page of `actions.rows`.
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: Dashboard retrieved
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingMyTeamDashboard"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |-
            The Training app is not accessible to the caller (`access_denied`), or the caller is neither a people-manager nor a Training admin (`forbidden`).
            `insufficient_permissions` — the token lacks `read:training` (#7183).
  "/training/my_team/{id}":
    get:
      tags:
      - Training
      summary: One team member's training
      description: |
        A learner's drill-in: their four counts, everything outstanding, the
        completed history, their issued certificates, and `departments` — every
        department they belong to here, as the web page's subtitle lists them,
        where the organization's field settings show Department to the caller.

        Resolved THROUGH the caller's own team scope, so a manager cannot address
        someone else's report by id — a learner outside the caller's team is a
        404, the same answer a nonexistent id gets, so neither reveals the other.

        **`counts` is LEGACY and may overlap**, unchanged for shipped apps: by raw
        status (`assigned` = not begun, `in_progress` = started), with
        `counts.overdue` counting outstanding rows past their due date that are
        ALSO in `assigned` or `in_progress`. **`bucket_counts`** is the partition
        the web and phone drill-ins show (Training::LearningStatus): not_started +
        in_progress + overdue is everything outstanding, an overdue course counted
        once, as overdue.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The team member's user id, as returned in the dashboard's `members[].id`.
        schema:
          type: integer
          example: 41157
      responses:
        '200':
          description: Member retrieved
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingMyTeamMember"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`), or the caller is not
            a team lead (`forbidden`), or the token lacks `read:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such user, or that person is not on the caller's team (error
            code `not_found` for both).
  "/training/my_team/{id}/remind":
    post:
      tags:
      - Training
      summary: Remind a team member about one course
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Nudge ONE enrollment. Per-enrollment rather than per-learner, because that
        is where the affordance sits on both surfaces — a Remind button on each
        outstanding row — and because the web door is per-enrollment too.

        **Which email is sent is decided server-side by what can honestly be
        said**: a course with a due date gets the due-date reminder ("due in N
        days"); everything else gets the general course reminder. The client does
        not choose, and there is one implementation behind all three doors (the
        two web ones and this) — they previously sent DIFFERENT emails under the
        same button name, which is why it was consolidated.

        Delivery is queued, so a 200 means "accepted and enqueued", not
        "delivered". `message` is the sentence to show the user and is the same
        one the web flashes.

        The enrollment must belong to the named team member; anything else is a
        404 rather than a silent no-op.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The team member's user id.
        schema:
          type: integer
          example: 41157
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Send `enrollment_id` for a course, or `path_enrollment_id`
                for a learning path.
              properties:
                enrollment_id:
                  type: integer
                  description: One of the member's own enrollment ids — from this
                    member's `open[].enrollment_id`, or from the dashboard's `overdue[].enrollment_id`.
                    Must belong to this member.
                  example: 22989
                path_enrollment_id:
                  type: integer
                  description: 'One of the member''s own learning-path enrollment
                    ids — from this member''s `paths[]` or the dashboard''s `overdue_paths[]`;
                    a cancelled one is not listed there and answers 404. A path reminder
                    carries its due date, so a finished or undated path answers `reminded:
                    false` with the reason.'
                  example: 5120
      responses:
        '200':
          description: Reminder enqueued
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  required:
                  - reminded
                  - message
                  properties:
                    enrollment_id:
                      type: integer
                      example: 22989
                      description: For a course reminder.
                    path_enrollment_id:
                      type: integer
                      example: 5120
                      description: For a learning-path reminder.
                    reminded:
                      type: boolean
                      example: true
                    message:
                      type: string
                      description: The sentence to surface to the user.
                      example: Reminder sent to Priya Raman.
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`), or the caller is not
            a team lead (`forbidden`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: That person is not on the caller's team, or the enrollment
            is not theirs (error code `not_found` for both).
  "/training/courses/{course_id}/reviews/{id}":
    patch:
      tags:
      - Training
      summary: Edit your own course review
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Edit the caller's own review — the native mirror of the web "Edit your
        review" CTA on the ratings block.

        **Send only what changes.** `rating`, `title` and `content` are each
        applied only when the key is PRESENT, so a client can patch the star
        rating without resending prose it is not touching. Sending `title` or
        `content` blank is a real change (it clears the field); a blank `rating`
        fails its presence validation rather than silently keeping the old stars.
        Sending none of the three is 422 `nothing_to_update`.

        **AUTHOR ONLY**, with no time window — the web gate verbatim. A Training
        admin moderates reviews through the admin surface but may not rewrite
        words attributed to a learner, so an admin editing someone else's review
        is 403 `forbidden`.

        **Not re-run on edit**, both matching the web: the verified-purchase
        badge (an edit cannot change what was purchased) and `is_approved` — an
        edit does NOT send an approved review back to moderation, which would
        make it vanish from the list the client is displaying.

        The response carries the **recomputed** `rating` histogram, because
        editing the stars moves it. Payload is identical to the POST's
        (`TrainingReviewWriteResult`).
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
          example: 37
      - name: id
        in: path
        required: true
        description: The review's own id — as returned in `my_review.id` by the reviews
          list.
        schema:
          type: integer
          example: 110
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewUpdateRequest"
      responses:
        '200':
          description: Review updated
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |-
            The Training app is not accessible to the caller (`access_denied`), or the review belongs to someone else (`forbidden`).
            `insufficient_permissions` — the token lacks `write:training` (#7183).
        '404':
          description: No such course visible to this caller, or no such review on
            it — including a review that belongs to another course or another tenant
            (error code `not_found` for all, so none reveals the others).
        '422':
          description: |
            `nothing_to_update` (no editable key sent) in the standard
            `{ error: { code, message } }` envelope, or a validation failure
            (out-of-range `rating`, over-long `title`/`content`) in the per-field
            `{ errors: [{ field, message, code }] }` shape.
  "/training/learning_paths/{learning_path_id}/reviews/{id}":
    patch:
      tags:
      - Training
      summary: Edit your own learning-path review
      description: |
        **Requires the `write:training` scope.** Every first-party token carries
        it (login, refresh and SSO all mint the read/write pair together); a token
        without it gets 403 `insufficient_permissions`. Training READS are not
        scope-gated.

        Edit the caller's own review of a learning path. Identical body, payload
        and error codes to the course variant above — reviews are polymorphic, so
        one controller serves both (`subject.type` is `path`).
      security:
      - BearerAuth: []
      parameters:
      - name: learning_path_id
        in: path
        required: true
        schema:
          type: integer
          example: 1
      - name: id
        in: path
        required: true
        schema:
          type: integer
          example: 110
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TrainingReviewUpdateRequest"
      responses:
        '200':
          description: Review updated
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/TrainingReviewWriteResult"
                - type: object
                  properties:
                    unread_notification_count:
                      "$ref": "#/components/schemas/UnreadNotificationCount"
                    _meta:
                      "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`), or the review is someone
            else''s (`forbidden`), or the token lacks `write:training` (`insufficient_permissions`,
            #7183).'
        '404':
          description: No such learning path visible to this caller, or no such review
            on it (`not_found`).
        '422':
          description: "`nothing_to_update`, or a validation failure — see the course
            variant above."
  "/training/courses/{course_id}/sessions":
    get:
      tags:
      - Training
      summary: Bookable sessions for an instructor-led course
      description: |
        The "choose your session" list — the native counterpart of My Learning →
        an ILT course → the session-list dialog.

        **Only bookable rows.** `scheduled` + starting in the future, ordered
        soonest-first. A past or cancelled session is omitted rather than returned
        with a closed CTA: you cannot register into either, and a list that led
        with last month's cohort is the wrong answer to "which session shall I
        take".

        **`cta_action` is the row's verb**, resolved server-side with the same
        precedence the web row uses, so no client re-derives it:
        `registered` · `waitlisted` (a seat you already hold — always wins) ·
        `closed` (registration window shut) · `join_waitlist` (full) ·
        `switch` (you hold a seat on a DIFFERENT session of this course) ·
        `register`.

        **`capacity` is computed once for the whole list** in a single grouped
        query, so a course with a weekly schedule costs no per-row counting.
        `taken` counts occupied seats (registered/attended/completed);
        `waitlist_count` is separate and does not consume capacity.

        **Print `formatted_date` / `formatted_time`.** They are composed server-side
        in the SESSION's zone and are ready to render, which is what the web does — a
        class happens where it happens, not where the viewer is. `starts_at` /
        `ends_at` carry the venue's UTC offset and are for sorting, countdowns and
        calendar entries; formatting them yourself is how a learner ends up seeing
        their own clock instead of the class's. The pre-converted `starts_at_local` /
        `ends_at_local` were removed on 2026-09-01; the venue-local instant now lives
        in `starts_at` itself.

        **`can_register` reflects BOTH switches** — the tenant-wide Training
        setting AND this course's own `allow_self_enrollment`. When it is false
        the rows are still returned (so the schedule can be displayed) but
        `POST /training/sessions/{id}/register` will answer 403; disable the
        button rather than letting the user discover it on tap.

        `my_registration` reports the caller's hold in WHATEVER state, including
        `attended`/`completed`, which the per-row `cta_action` deliberately
        ignores — see the note on the register endpoint.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: course_id
        in: path
        required: true
        schema:
          type: integer
        description: The instructor-led course.
      responses:
        '200':
          description: The bookable session list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  course:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      delivery_mode:
                        type: string
                      instructor_led:
                        type: boolean
                  sessions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/TrainingSessionRow"
                  my_registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  can_register:
                    type: boolean
                    description: Both self-enrollment switches are on.
                  series:
                    type: array
                    description: The course's recurring classes, one entry each, to
                      join or leave with `/training/series/{id}/membership`. Their
                      dates are ALSO in `sessions`, as before, each with its own seat.
                      Empty when the course has none with a date still to run.
                    items:
                      "$ref": "#/components/schemas/TrainingSeriesRow"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`), or the token lacks `read:training`
            (`insufficient_permissions`, #7183).'
        '404':
          description: No such course, or it is not visible to this caller (`not_found`).
        '422':
          description: The course is not instructor-led, so it has no sessions (`not_instructor_led`).
  "/training/sessions/{id}":
    get:
      tags:
      - Training
      summary: One instructor-led session
      description: |
        A single session addressed by its own id — the same row shape the list
        returns, for a client that holds a session id (from a reminder, a deep
        link, or a previously fetched list) without its course.

        Flat rather than nested under the course for the same reason the `qa/`
        routes are: the id addresses the row on its own and the course is derived
        from it. Visibility is still enforced through the course — a session whose
        course has been unpublished or archived answers 404.

        **Required Scopes:** `read:training` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; Training's reads asked no scope
        before it). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:training") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  course:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      delivery_mode:
                        type: string
                      instructor_led:
                        type: boolean
                  session:
                    "$ref": "#/components/schemas/TrainingSessionRow"
                  my_registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  can_register:
                    type: boolean
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'App not accessible (`access_denied`), or the token lacks `read:training`
            (`insufficient_permissions`, #7183).'
        '404':
          description: No such session in this business, or its course is no longer
            visible (`not_found`).
  "/training/sessions/{id}/register":
    post:
      tags:
      - Training
      summary: Take (or move) the caller's seat on a session
      description: |
        **Requires the `write:training` scope** (see the Training write-scope note
        on the review and Q&A write endpoints). No request body.

        **One endpoint covers register, switch AND join-waitlist.** The web has
        three actions; they differ only in the message they flash. Underneath,
        `Training::SessionRegistrationService#switch` is documented as the same
        operation as `#register` ("it replaces whatever active hold the learner
        has for the course"), and `#register` already falls back to the waitlist
        when a session is full. So: POST here to book a seat, to MOVE your seat
        from another session of the same course, or to queue for a full one.

        **`intent` says what you ASKED for**, which is a different question from
        what the seats allow. `register` (the default when the field is absent)
        takes a seat and falls back to the waitlist if the session is full;
        `waitlist` queues you deliberately even when a seat is free — the two
        buttons a session row draws ("Choose this session" / "Join waitlist").
        An unrecognised value is a 422 `invalid_intent`, NOT a silent fallback:
        guessing `register` for a mistyped `waitlist` would book a seat you never
        asked for. The response echoes `intent`, so `intent: register` alongside
        `status: waitlisted` is exactly how you detect that the last seat went
        between the list and the tap.

        **Read `status` from the response — do not predict it.** It is
        `registered` or `waitlisted`, decided under a row lock on the session, so
        a seat that filled between your GET and your POST cannot over-book. The
        returned `session` already reflects your write (its `capacity` and
        `cta_action` are re-read afterwards), so a client can re-render the row
        without a second request.

        **Idempotent.** Posting again for a session you already hold returns that
        same registration rather than creating a duplicate.

        **Registering is also how you ENROLL.** For an instructor-led course the
        service creates the `TrainingEnrollment` if you have none, applying the
        same prerequisite gating as catalog enrollment — a prerequisite failure
        comes back 422 with the service's own message.

        **Refusals**, each with a stable `error.code`:

        - `self_enrollment_disabled` (403) — either the tenant-wide Training
          setting or this course's `allow_self_enrollment` is off. `can_register`
          on the two GETs above reports the same thing in advance.
        - `registration_closed` (422) — the session's registration window has shut.
        - `already_attended` (422) — you have already sat a session for this
          course. Refused rather than re-booked: moving that hold would discard
          earned attendance credit. A re-assignment opens a new attempt.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                intent:
                  type: string
                  enum:
                  - register
                  - waitlist
                  default: register
                  description: What the caller is asking for. Omit for the historical
                    behaviour (take a seat, fall back to the waitlist when full).
      responses:
        '200':
          description: The seat, and the row re-read after the write.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                    - registered
                    - waitlisted
                    - attended
                    description: '`attended` is returned by the idempotent path only
                      — you already sat this session, so nothing was booked and your
                      seat is spent. Treat it as "no change", not as a new booking.'
                  waitlisted:
                    type: boolean
                  intent:
                    type: string
                    enum:
                    - register
                    - waitlist
                    description: The intent that was applied, echoed back.
                  message:
                    type: string
                    description: The same sentence the web flashes.
                  registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  session:
                    "$ref": "#/components/schemas/TrainingSessionRow"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), the token lacks `write:training`
            (`insufficient_permissions`), or self-enrollment is off for the tenant
            or the course (`self_enrollment_disabled`).
        '404':
          description: No such session in this business, or its course is no longer
            visible (`not_found`).
        '422':
          description: "`registration_closed`, `already_attended`, or a prerequisite
            failure from the enrollment step."
  "/training/sessions/{id}/registration":
    delete:
      tags:
      - Training
      summary: Give up the caller's seat, or leave the waitlist
      description: |
        **Requires the `destructive:training` scope** — cancelling a
        registration is a delete-level act (#7183, #6721). No request body.

        The correction path for `POST /training/sessions/{id}/register` — a
        learner who booked a seat can release it, and one who joined a queue can
        leave it. This is the session row's "Leave waitlist" state, and the
        counterpart of the web's `DELETE .../cancel_registration`.

        **DELETE on the REGISTRATION sub-resource, not on the session** — the
        session is not being removed, the caller's hold on it is.

        **Cancelling is what frees a seat**, so the service promotes the head of
        that session's waitlist inside the same call. No client needs to do that
        arithmetic, and no second request is required.

        **An `attended` hold cannot be cancelled** — it answers 422
        `no_active_registration` and the record is left intact. Erasing it would
        discard the learner's earned attendance credit; the session list reports
        the seat as attended instead. Same rule the web states outright.

        Scoped to THIS session: a hold on a different session of the same course
        is not cancelled by this route.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The hold was cancelled, and the row re-read afterwards.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cancelled:
                    type: boolean
                  message:
                    type: string
                  registration:
                    "$ref": "#/components/schemas/TrainingSessionRegistrationSummary"
                  session:
                    "$ref": "#/components/schemas/TrainingSessionRow"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `destructive:training`
            (`insufficient_permissions`).
        '404':
          description: No such session in this business, or its course is no longer
            visible (`not_found`).
        '422':
          description: "`no_active_registration` — the caller holds nothing cancellable
            on this session (including the attended case above)."
  "/training/series/{id}/membership":
    post:
      tags:
      - Training
      summary: Join a recurring class
      description: |
        **Requires the `write:training` scope.** No request body.

        The "Recurring class" card's **Join this class** — the native twin of the
        web's join on the course page. One call takes a seat on every date of the
        class still open to book (a full date puts the caller on that date's
        waitlist), puts the class on their calendar as one series, and sends one
        notice. The same self-booking rule as booking one seat applies:
        `can_register` on the sessions list reports it in advance.

        **201** when the caller joined; **200** when they were already in it
        (nothing changes — a date they gave up is not booked again). Either way the
        response carries the refreshed `series` entry and the sentence to show.

        **Refusals**, each with a stable `error.code`:

        - `self_enrollment_disabled` (403) — self-enrollment is off for the
          tenant or the course, or the course must be purchased first.
        - `join_refused` (422) — the learner cannot be placed (for example a
          prerequisite is not finished); `message` is the reason.
        - `series_over` (422) — the class has no date left to join.
        - `not_found` (404) — no such class, it has ended, or its course is not
          visible to the caller.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The recurring class (`series[].id` on the sessions list).
      responses:
        '201':
          description: Joined.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingSeriesMembershipResponse"
        '200':
          description: Already in the class; nothing changed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TrainingSeriesMembershipResponse"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), the token lacks `write:training`
            (`insufficient_permissions`), or self-booking is closed (`self_enrollment_disabled`).
        '404':
          description: No such active class in this business, or its course is not
            visible (`not_found`).
        '422':
          description: "`join_refused` or `series_over`."
    delete:
      tags:
      - Training
      summary: Leave a recurring class
      description: |
        **Requires the `destructive:training` scope** — like cancelling a seat,
        leaving gives up the caller's places on every date to come. No request body.

        The card's **Leave the class**. Seats and waitlist places on the dates to
        come are released (the head of each waitlist moves up), the class comes
        off the caller's calendar, and what they already attended stays on their
        record. An ended class can still be left.

        To miss just one date and stay in the class ("Can't make the next one"),
        use `DELETE /training/sessions/{next_session.id}/registration` instead.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Left; the refreshed entry reads `member` false.
          content:
            application/json:
              schema:
                type: object
                properties:
                  series:
                    "$ref": "#/components/schemas/TrainingSeriesRow"
                  message:
                    type: string
                    example: You've left Weekly safety huddle.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `destructive:training`
            (`insufficient_permissions`).
        '404':
          description: No such class in this business, or its course is not visible
            (`not_found`).
        '422':
          description: "`not_a_member` — the caller is not in this class."
  "/training/check_in":
    post:
      tags:
      - Training
      summary: Check in to a class by its QR code or typed code
      description: |
        **Requires the `write:training` scope.**

        The native twin of the web's class check-in: send the payload the room's
        QR code holds as `code`, or the code typed off the instructor's screen as
        `manual_code`. It runs the same check-in rule as the web and Calendar (the
        time window, the roster, replay protection, a cap on typed attempts), and a
        check-in marks the learner present on the class roster. Only Training
        access is asked — a workspace without the Calendar app checks in too.

        **A refusal is a 200**, not an HTTP error: `{ ok: false, status:
        "rejected", cause, message }`, the envelope of
        `POST /calendar/attendance/scan`. Show `message`. `cause` is stable —
        `not_invited` (not on the class's roster), `expired`, `invalid_code`,
        `rate_limited`, `qr_disabled` among others.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  description: The scanned QR code's payload.
                manual_code:
                  type: string
                  description: The code typed off the instructor's screen (case and
                    spaces are ignored).
      responses:
        '200':
          description: Checked in, already checked in, or refused (see `ok`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  status:
                    type: string
                    enum:
                    - recorded
                    - walk_in
                    - already_checked_in
                    - rejected
                  message:
                    type: string
                    example: You're checked in.
                  cause:
                    type: string
                    description: Present when `ok` is false.
                    example: not_invited
                  course_id:
                    type: integer
                    nullable: true
                    description: The class's course, to open after a check-in. Null
                      for a code that is not a class's.
                  session_id:
                    type: integer
                    nullable: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible (`access_denied`), or the token lacks `write:training`
            (`insufficient_permissions`).
        '422':
          description: "`missing_code` — neither `code` nor `manual_code` was sent."
  "/leader-rounds/due":
    get:
      tags:
      - Leader Rounds
      summary: Rounds due on the caller's direct reports
      description: |
        The caller's due list — every ACTIVE direct report, with how long since
        each was last rounded on and whether that is past the tenant's cadence.

        **Direct reports only, and all of them.** A skip-level round can be
        logged on anyone in the caller's subtree, but it carries no obligation —
        otherwise a director would be "overdue" on hundreds of people on day
        one. Terminated and suspended users never appear.

        **Leaders only, but never an error.** A non-leader gets
        `entries: []` and `due_count: 0`, so a client can call this
        unconditionally.

        Only SUBSTANTIVE completed rounds clear a row (a completed round with
        every answer blank scores nothing — guardrail #1 against rounding
        theatre), and a recorded skip suppresses the obligation without
        pretending the person was rounded on: their entry carries
        `due: false` with `never_rounded: true` and no `last_rounded_on`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Due list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - cadence_days
                - due_count
                - entries
                properties:
                  cadence_days:
                    type: integer
                    description: The tenant's rounding cadence in days (the `default_cadence_days`
                      app setting, clamped to 1..365). Published so a client need
                      not hardcode 30.
                    example: 30
                  due_count:
                    type: integer
                    description: 'How many entries carry `due: true`.'
                    example: 3
                  calendar_enabled:
                    type: boolean
                    description: Whether calendar pairing works here (the Calendar
                      app is on). When false every entry's `scheduled` is false, and
                      "Put on calendar" would be refused `calendar_disabled` — hide
                      it, as the web does.
                  can_schedule_rounding:
                    type: boolean
                    description: 'Draw "Put on calendar" on this: `calendar_enabled`
                      AND the Calendar app opens for this leader (published to them,
                      in its groups and licence) AND a credential holding write:leader_rounds,
                      which `POST /leader-rounds/calendar-pairings` asks (a signed-in
                      session always does). Past the Calendar door the POST is refused
                      `calendar_not_open`.'
                  can_remove_scheduled_rounding:
                    type: boolean
                    description: 'Offer removing a row''s `scheduled_pairing_id` on
                      this: `calendar_enabled` AND destructive:leader_rounds, which
                      `DELETE /leader-rounds/calendar-pairings/{id}` asks.'
                  can_start_round:
                    type: boolean
                    description: 'Whether this person has anyone to round on (the
                      web''s "+ New Round" button). False for an admin or manager
                      with nobody in their line: hide the capture button — its person
                      picker would be empty and every save refused.'
                    example: true
                  delegated_to:
                    type: object
                    nullable: true
                    description: 'Set while this leader''s rounding is delegated:
                      their team is on the delegate''s due list (the web''s "Your
                      rounding is with …"). `until` is the last day, null when open-ended.'
                    properties:
                      delegate_id:
                        type: integer
                      name:
                        type: string
                      until:
                        type: string
                        format: date
                        nullable: true
                  covering:
                    type: array
                    description: Leaders whose teams are on this person's due list
                      for now (the web's "You're covering …").
                    items:
                      type: object
                      properties:
                        principal_id:
                          type: integer
                        name:
                          type: string
                  pacing:
                    type: object
                    description: This period's pacing plan for the caller — how many
                      rounds the cadence expects of them in the current window, how
                      many are done, and whether they are on pace. Emitted on every
                      response; previously undocumented.
                    properties:
                      window:
                        type: string
                        example: 2026-09
                      window_label:
                        type: string
                        example: September
                      target:
                        type: integer
                        example: 6
                      done:
                        type: integer
                        example: 2
                      skipped:
                        type: integer
                        example: 0
                        description: How many of done were settled by a skip, not
                          a round.
                      remaining:
                        type: integer
                        example: 4
                      paced:
                        type: boolean
                        example: false
                  reminders:
                    type: object
                    description: The weekly-reminder card's state — the same three
                      fields `pause-reminders` and `resume-reminders` reply with,
                      so a freshly opened due list can show a pause and offer Resume.
                    required:
                    - reminders_enabled
                    - paused
                    - paused_until
                    properties:
                      reminders_enabled:
                        type: boolean
                        description: The TENANT-level reminder switch.
                        example: true
                      paused:
                        type: boolean
                        example: false
                      paused_until:
                        type: string
                        format: date-time
                        nullable: true
                        description: When the caller's snooze ends; null when not
                          paused.
                  entries:
                    type: array
                    items:
                      type: object
                      required:
                      - subject_id
                      - subject_name
                      - due
                      - never_rounded
                      properties:
                        subject_id:
                          type: integer
                          example: 1752
                        subject_name:
                          type: string
                          example: Marcus Bell
                        last_rounded_on:
                          type: string
                          format: date
                          nullable: true
                          description: Null when never rounded.
                          example: '2026-07-12'
                        days_since:
                          type: integer
                          nullable: true
                          description: Null when never rounded.
                          example: 37
                        due:
                          type: boolean
                          description: Past cadence (or never rounded) AND not suppressed
                            by a recorded skip. This is the flag a client badges on
                            — but badge it in TEXT, not colour alone.
                          example: true
                        never_rounded:
                          type: boolean
                          example: false
                        last_rounded_by:
                          type: object
                          nullable: true
                          description: 'Who held the round behind the last-rounded
                            date when it was not the caller — a past delegate, or
                            the leader of a team being covered. Show "by <name>":
                            the caller often cannot open that round. Null when it
                            was the caller''s own.'
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                        last_skipped_on:
                          type: string
                          format: date
                          nullable: true
                          description: 'When a skip was last recorded for this person,
                            else null. WHY a row is not due is not derivable from
                            the other fields: a recorded skip suppresses the obligation
                            without counting as a round, so a skipped-and-never-rounded
                            subject reads `due: false` / `never_rounded: true` / `last_rounded_on:
                            null` — identical to someone whose cadence simply has
                            not lapsed. A client must render those two differently
                            ("Never rounded", no Due badge, because a skip is recorded).'
                          example: '2026-08-18'
                        subject_can_open:
                          type: boolean
                          description: 'Whether this person can open Leader Rounds.
                            False means a skip''s written reason never reaches them:
                            warn on Skip as the web and phone due lists do ("<Name>
                            can''t open Leader Rounds, so they won''t see this reason").'
                          example: true
                        job_title:
                          type: string
                          nullable: true
                          description: The subject's credential, null when the tenant
                            does not record one. How a person is identified on a floor
                            ("Marcus Bell, RN") — two Bells on one unit is normal,
                            so a bare name is genuinely ambiguous.
                          example: RN
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute URL — the photo, or a generated initials
                            image when there is none.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/…
                        scheduled:
                          type: boolean
                          description: True when a recurring rounding meeting carrying
                            a real calendar event exists for this leader/subject pair.
                            DISPLAY ONLY — it never influences due-ness (CalendarPairing
                            feeds nothing in DueList/CoverageReport), so a scheduled
                            round is still owed until it is logged.
                          example: true
                        scheduled_for:
                          type: string
                          format: date
                          nullable: true
                          description: The next non-cancelled occurrence, or null
                            when `scheduled` is true but the series has not materialised
                            that far ahead — a real state, not an error.
                          example: '2026-08-25'
                        scheduled_pairing_id:
                          type: integer
                          nullable: true
                          description: 'The CalendarPairing id, present only when
                            `scheduled` is true. Pass it to `DELETE /leader-rounds/calendar-pairings/{id}`
                            to remove the standing meeting. Adding a pairing that
                            already exists answers 200 with this same id and `replayed:
                            true`, and changes nothing.'
                          example: 3
                        cadence_days:
                          type: integer
                          description: The cadence that governs THIS person — the
                            one set by the Rounding Program that applies to them,
                            else the tenant default (the top-level `cadence_days`).
                            `due` is measured against this number, so it can differ
                            row to row.
                          example: 30
                        program_name:
                          type: string
                          nullable: true
                          description: The Rounding Program that applies to this person
                            (it may set their template, their cadence or both); null
                            when no program matches and the tenant default applies.
                          example: New Grad Residency
                        template_id:
                          type: integer
                          nullable: true
                          description: 'The template a round with this person opens
                            on: their Rounding Program''s active template, else the
                            account default — what the web''s New Round preselects.
                            Send it as `template_id` on POST /rounds.'
                        template_name:
                          type: string
                          nullable: true
                        this_month:
                          type: boolean
                          description: True when this person is in the current pacing
                            window's list — up next or already done this window (see
                            `pacing`). A client can group the due list into "this
                            month" and the rest from this flag.
                          example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Leader Rounds is not enabled for the business (`app_disabled`)
            or not accessible to the caller (`app_forbidden`).
  "/leader-rounds/widget":
    get:
      tags:
      - Leader Rounds
      summary: The Leader Rounds home card for the caller, in one read
      description: |
        Everything a client needs to draw its own Leader Rounds widget — built
        for the MangoApps widget, which calls as the person through
        `POST /auth/impersonate`. `view` says which card the caller gets:

        - **director** — a Leader Rounds app admin or business admin (the whole
          account), or a leader whose team includes other leaders. Carries
          `org` (the "My org" tab) and `leader` (the "My rounds" tab).
        - **leader** — anyone else with people to round on (the same rule as the
          Workforce dashboard widget). Carries `leader`.
        - **employee** — everyone else. Carries `subject`.

        Every number is the one the page behind the card's links shows: the due
        list and the month's pacing (`/due`), coverage over the Team Rollup's
        rolling 30-day window (`/team`), and the overdue-issue count the
        Workforce dashboard widget prints. Each list carries at most 3 rows
        beside its full `total`. Every `*_url` is an absolute link to the
        Workforce web page; the `*_mobile_url` beside it is that page's phone
        version (`/m/…`), which a phone or the Hub phone app opens instead.

        Issue rows (`leader.issues`, `subject.assigned`, `subject.raised`) are
        the row shape `/leader-rounds/issues` returns, plus `completed_at`
        (when it was resolved — "Fixed Aug 21"; null while open), `url` and
        `mobile_url` (the issue's phone page).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller's card
          content:
            application/json:
              schema:
                type: object
                required:
                - view
                - cadence_days
                - view_all_url
                properties:
                  view:
                    type: string
                    enum:
                    - director
                    - leader
                    - employee
                  cadence_days:
                    type: integer
                    description: The account's default rounding cadence, in days.
                    example: 30
                  view_all_url:
                    type: string
                    description: The Leader Rounds home page (it routes leaders and
                      others to their own view).
                  view_all_mobile_url:
                    type: string
                    description: The phone home (`/m/apps/leader-rounds`).
                  leader:
                    type: object
                    description: Present for `leader` and `director`.
                    properties:
                      window_label:
                        type: string
                        description: The pacing month, e.g. "October".
                      window_end:
                        type: string
                        format: date
                      days_left:
                        type: integer
                        description: Whole days after today left in the month (tenant
                          calendar).
                        example: 22
                      done:
                        type: integer
                        description: People rounded on (or skipped) this month — "4
                          of 7".
                        example: 4
                      skipped:
                        type: integer
                        description: How many of done were settled by a skip, not
                          a round.
                        example: 0
                      target:
                        type: integer
                        description: People this month calls for, by each person's
                          cadence.
                        example: 7
                      paced:
                        type: boolean
                        description: True when cadences are longer than a month, so
                          `due` is this month's list rather than everyone past cadence.
                      can_start_round:
                        type: boolean
                        description: Whether the caller has anyone to round on. Hide
                          the round button when false.
                      delegated_to:
                        type: object
                        nullable: true
                        description: Set while the caller's rounding is handed to
                          someone else — their team is on that person's list.
                        properties:
                          delegate_id:
                            type: integer
                          name:
                            type: string
                          until:
                            type: string
                            format: date
                            nullable: true
                      overdue_issues:
                        type: integer
                        description: Open, past-due issues on the rounds the caller
                          can see — "1 issue past due".
                        example: 1
                      assigned_open_issues:
                        type: integer
                        description: Open issues handed to the caller from a round
                          they did not lead and that is not about them — "2 issues
                          waiting on you".
                        example: 0
                      rounds_url:
                        type: string
                        description: View All on the Due tab — My Rounding, the full
                          due list.
                      rounds_mobile_url:
                        type: string
                        description: Its phone page — the phone home, which shows
                          the same due list.
                      issues_url:
                        type: string
                        description: View All on the Issues tab — the Issue Ledger
                          filtered to open issues (`?open_only=1`), the set `issues.total`
                          counts.
                      issues_mobile_url:
                        type: string
                        description: The phone Issue Ledger, filtered to open.
                      due:
                        type: object
                        properties:
                          total:
                            type: integer
                            description: Everyone due now — "3 Due now", "Round on
                              3".
                            example: 3
                          items:
                            type: array
                            items:
                              type: object
                              properties:
                                subject_id:
                                  type: integer
                                name:
                                  type: string
                                job_title:
                                  type: string
                                  nullable: true
                                avatar_url:
                                  type: string
                                  nullable: true
                                status:
                                  type: string
                                  enum:
                                  - never_rounded
                                  - overdue
                                  - due_today
                                  - upcoming
                                  description: "`never_rounded` and `overdue` only
                                    for someone the due list calls due. A person a
                                    recorded skip keeps quiet (a paced month list
                                    carries them) is `due_today` or `upcoming`, with
                                    `skipped: true`."
                                days_overdue:
                                  type: integer
                                  nullable: true
                                  description: Days past `due_on` — "9 days late".
                                    Set only when `status` is overdue.
                                due_on:
                                  type: string
                                  format: date
                                  nullable: true
                                  description: The day this person's cadence runs
                                    out — after the later of their last round and
                                    their last skip, the "Next" My Rounding prints.
                                    Null when never rounded and never skipped.
                                last_rounded_on:
                                  type: string
                                  format: date
                                  nullable: true
                                last_skipped_on:
                                  type: string
                                  format: date
                                  nullable: true
                                  description: When a skip was last recorded for this
                                    person, else null.
                                skipped:
                                  type: boolean
                                  description: True when a recorded skip, not a round,
                                    is why this person is not due — "Skipped Oct 2".
                                cadence_days:
                                  type: integer
                                joined_on:
                                  type: string
                                  format: date
                                  nullable: true
                                  description: When they joined this business — "Joined
                                    Aug 28".
                                new_round_url:
                                  type: string
                                new_round_mobile_url:
                                  type: string
                      issues:
                        type: object
                        properties:
                          total:
                            type: integer
                            description: Open issues on the rounds the caller can
                              see — "Issues (2)".
                          items:
                            type: array
                            description: Open issues, oldest due first — the row shape
                              `/leader-rounds/issues` returns, plus `completed_at`,
                              `url` and `mobile_url`.
                            items:
                              type: object
                              additionalProperties: true
                  org:
                    type: object
                    description: Present for `director` only.
                    properties:
                      window_days:
                        type: integer
                        example: 30
                      coverage_pct:
                        type: integer
                        example: 72
                      coverage_expected:
                        type: boolean
                        description: False when nobody was due in the window — show
                          the caption, not 0%.
                      coverage_delegated_out:
                        type: boolean
                      coverage_caption:
                        type: string
                      open_issues:
                        type: integer
                        example: 14
                      stuck_issues:
                        type: integer
                        description: 'The Team Rollup''s Red tile: issues raised in
                          rounds these leaders led that are overdue, or closed as
                          Won''t fix.'
                        example: 3
                      viewer_is_app_admin:
                        type: boolean
                      root_label:
                        type: string
                        enum:
                        - All Teams
                        - My Org
                        description: '"All Teams" for an app admin, "My Org" for a
                          leader.'
                      stuck_review:
                        type: object
                        description: The Review N Stuck button. `count` is the Red
                          tile (`stuck_issues`); `url` opens the Issue Ledger filtered
                          to red (overdue, or closed as Won't fix). For an app admin
                          the list holds exactly `count` issues; for a leader it can
                          hold more, because it also lists red issues on rounds about
                          them and on rounds led by someone in their line who no longer
                          leads anyone, which `count` leaves out.
                        properties:
                          count:
                            type: integer
                            example: 3
                          url:
                            type: string
                          mobile_url:
                            type: string
                            description: The phone Issue Ledger, filtered to red.
                      team_url:
                        type: string
                      team_mobile_url:
                        type: string
                      leaders:
                        type: object
                        properties:
                          total:
                            type: integer
                          items:
                            type: array
                            description: 'Worst first. For a leader ("My org"), the
                              layer below them, each unit its whole subtree. For an
                              app admin ("All teams"), the Team Rollup''s By Leader
                              rows: every leader in the account, each scored on their
                              own team.'
                            items:
                              type: object
                              properties:
                                leader_id:
                                  type: integer
                                name:
                                  type: string
                                title:
                                  type: string
                                  nullable: true
                                unit:
                                  type: string
                                  nullable: true
                                  description: Their department, else their primary
                                    location — "Store 112".
                                avatar_url:
                                  type: string
                                  nullable: true
                                rounded_subjects:
                                  type: integer
                                team_size:
                                  type: integer
                                coverage_pct:
                                  type: integer
                                coverage_expected:
                                  type: boolean
                                coverage_delegated_out:
                                  type: boolean
                                coverage_caption:
                                  type: string
                                  description: '"2 of 8 rounded · 25%", or why there
                                    is no score.'
                                band:
                                  type: string
                                  nullable: true
                                  enum:
                                  - on_track
                                  - behind
                                  - far_behind
                                  description: The Team Rollup's band (80% / 50%).
                                    Null when coverage was not expected.
                                band_label:
                                  type: string
                                  nullable: true
                                open_issues:
                                  type: integer
                                stuck_issues:
                                  type: integer
                                  description: 'The row''s red count on the Team Rollup:
                                    overdue, or closed as Won''t fix.'
                                url:
                                  type: string
                                mobile_url:
                                  type: string
                  subject:
                    type: object
                    description: Present for `employee` only.
                    properties:
                      rounds_count:
                        type: integer
                        description: Rounds about the caller, skips included. 0 —
                          "No rounds logged with you yet".
                      skipped_count:
                        type: integer
                      last_round:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          occurred_on:
                            type: string
                            format: date
                          leader_id:
                            type: integer
                          leader_name:
                            type: string
                          leader_job_title:
                            type: string
                            nullable: true
                          leader_avatar_url:
                            type: string
                            nullable: true
                          issues_raised:
                            type: integer
                          url:
                            type: string
                          mobile_url:
                            type: string
                            description: The round's phone page.
                      next_round_on:
                        type: string
                        format: date
                        nullable: true
                        description: When the caller's cadence runs out — after the
                          later of their last round and that leader's latest skip
                          of them (or after the skip alone when they have only been
                          skipped). Null when there is neither.
                      overdue_issues:
                        type: integer
                      assigned:
                        type: object
                        description: Open issues handed to the caller from someone
                          else's round.
                        properties:
                          total:
                            type: integer
                          items:
                            type: array
                            description: The `/leader-rounds/my` assigned row shape
                              (with `read_only`), plus `completed_at`, `url` and `mobile_url`.
                            items:
                              type: object
                              additionalProperties: true
                      raised:
                        type: object
                        description: Issues raised in rounds about the caller.
                        properties:
                          total:
                            type: integer
                          open:
                            type: integer
                          items:
                            type: array
                            items:
                              type: object
                              additionalProperties: true
                      my_url:
                        type: string
                      my_mobile_url:
                        type: string
                        description: The phone Rounds About Me.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Leader Rounds is not enabled for the business (`app_disabled`),
            not accessible to the caller (`app_forbidden`), or the token lacks `read:leader_rounds`
            (`insufficient_permissions`).
  "/leader-rounds/templates":
    get:
      tags:
      - Leader Rounds
      summary: Rounding templates and their question sets
      description: |
        The capture form's question set — every ACTIVE template in the tenant
        with its ordered, typed questions.

        **Why this endpoint exists:** `POST /leader-rounds/rounds` takes
        `answers` keyed by question id, and without this there was no way to
        learn a question id, its prompt, its type, or which questions are
        required. The capture form is undrawable without it.

        **`field_type` is the field to render on.** It is the app's own
        `question_type` translated into the platform's shared form-field
        vocabulary, so a client drives its existing dynamic form renderer
        instead of carrying a private translation table:

        | `question_type`    | `field_type` | Control |
        |--------------------|--------------|---------|
        | `text`             | `textarea`   | Free text |
        | `scale`            | `rating`     | 1..5, bounds published per question |
        | `boolean`          | `radio`      | Yes / No |
        | `choice`           | `select`     | From `choices` |
        | `recognition_pick` | `lookup`     | Business-user picker, stores a user id |
        | `issue_capture`    | `textarea`   | The issue DESCRIPTION — see below |

        **`field_type` is not a complete rendering contract — `inputs` is.**
        Two question types need MORE THAN ONE control, and `field_type` names
        only the first:

        | `question_type`    | inputs |
        |--------------------|--------|
        | `recognition_pick` | `referenced_user_id` (lookup) + `value` (the citation text) |
        | `issue_capture`    | `description` + `assigned_to_id` + `due_date` + `priority` |

        Every question therefore carries an `inputs` array — one entry for a
        simple question, several for a composite — plus a `composite` boolean for
        clients that want to branch. Render every entry in `inputs` and a
        composite cannot be half-built.

        This is not hypothetical. Before `inputs` existed, the native capture
        form trusted `field_type: lookup` and drew a recognition_pick's person
        picker WITHOUT its citation field. `Answer#recognition_content` builds
        the recognition post body from that citation, so every recognition posted
        from mobile read "Recognized during a leader round on <date>." — a public
        compliment on a colleague's feed with no reason in it. Nothing in the
        payload revealed the missing half; it took someone comparing the mobile
        and web forms side by side.

        Each input names its own `submit_as` (`answers` or `issues`), because
        that is the other half easily got wrong: an `issue_capture`'s inputs post
        through the top-level `issues` hash, not `answers`, and produce a row on
        the stoplight ledger.

        `default_template_id` is the template to open on: the one an admin
        chose as the default on the Templates page, else the
        industry-NEUTRAL staff template, not the first row: ordering is by
        (round_type, name), so "first" would hand a first-time leader either New
        Hire 30-60-90 or — on a tenant with an industry pack installed — a
        variant whose required questions ask about patients and supplies.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Templates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - templates
                properties:
                  default_template_id:
                    type: integer
                    nullable: true
                    description: The template a client should open on. See above.
                    example: 1
                  can_start_round:
                    type: boolean
                    description: 'Same answer as `/leader-rounds/due`''s: the capture
                      form these templates draw can be saved only when true.'
                    example: true
                  templates:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - round_type
                      - questions
                      properties:
                        id:
                          type: integer
                          example: 1
                        name:
                          type: string
                          example: Staff Rounding
                        description:
                          type: string
                          nullable: true
                        round_type:
                          type: string
                          enum:
                          - staff
                          - new_hire
                          - senior_leader
                          - custom
                          example: staff
                        industry:
                          type: string
                          nullable: true
                          description: The industry pack a template belongs to — set
                            on the pack's own templates and kept on an admin's duplicate
                            of one; null on the industry-neutral starters and templates
                            built from scratch.
                          example:
                        cadence_days:
                          type: integer
                          nullable: true
                          description: Null means no obligation (ad-hoc rounds only).
                          example: 30
                        questions:
                          type: array
                          items:
                            "$ref": "#/components/schemas/LeaderRoundsTemplateQuestion"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not enabled (`app_disabled`), not accessible (`app_forbidden`),
            or the caller is not a leader (`leader_access_required`). This is the
            CAPTURE FORM's question set, so it carries the same leader gate the web
            capture screen does — a non-leader gets no Leader Rounds tabs in the native
            shell and could not submit the form anyway.
        '422':
          description: No active rounding template in the tenant (`no_active_template`)
            — an admin must activate one. A client should surface this rather than
            opening an empty form.
  "/leader-rounds/rounds":
    get:
      tags:
      - Leader Rounds
      summary: List rounds
      description: |
        Paginated rounds, newest `occurred_on` first.

        **`scope` selects the read tier** — the two web rounds surfaces
        expressed as one param:
        * `mine` (DEFAULT) — rounds the caller LED. The default is fixed for
          backward compatibility: widening it would hand every existing client
          rows it never asked for and may assume cannot appear.
        * `visible` — the full read tier: led ∪ subtree ∪ about-you, and the
          whole tenant for app admins. Without this a DIRECTOR had no API path
          to the rounds their own leaders logged.

        `leader_id` narrows to one leader inside the `visible` tier and implies
        it. A leader id outside the tier is refused (`leader_not_visible`, 403)
        rather than ignored — a silently-dropped filter returns a list that is
        not what the client asked for while looking like it is.

        Rows are list-shaped: no answers, no issues. Use
        `GET /leader-rounds/rounds/{id}` for the full record.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        schema:
          type: string
          enum:
          - mine
          - visible
          default: mine
        description: Read tier. See above.
      - name: leader_id
        in: query
        required: false
        schema:
          type: integer
        description: Narrow to one leader; implies `scope=visible`.
      - name: window
        in: query
        required: false
        schema:
          type: integer
          minimum: 7
          maximum: 365
        description: 'Narrow to rounds that OCCURRED in the last N days, so a drill-down
          from a coverage number keeps describing the period that number described.
          A blank or out-of-range value means NO date narrowing rather than a default:
          this list is a history, not a coverage measurement, and "everything" is
          a legitimate answer. The value actually applied is echoed as `window_days`
          (null when unwindowed).'
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Rounds retrieved successfully
          headers:
            X-Total-Count:
              description: Rows in the whole list, across every page.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages at the applied `per_page`.
              schema:
                type: integer
            X-Current-Page:
              description: The page this response holds (1-based).
              schema:
                type: integer
            X-Per-Page:
              description: The page size actually applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - rounds
                - meta
                properties:
                  rounds:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                      - type: object
                        properties:
                          substantive:
                            type: boolean
                            description: False for a completed round with every answer
                              blank and no issue (the web's "No answers" badge — coverage
                              does not count it, so the person stays due), and for
                              a draft or a skip.
                  window_days:
                    type: integer
                    nullable: true
                    description: The `window` value actually applied, echoed so a
                      client can tell an out-of-range request from an honoured one.
                      Null when no window narrowing was applied. The `window` parameter
                      description above has always promised this field; it was missing
                      from the schema.
                    example: 30
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: "`scope` outside the enum (`invalid_scope`). Note that an explicit
            `scope` is validated BEFORE `leader_id` upgrades it, so `?scope=bogus&leader_id=5`
            is still a 400."
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible; `leader_id` outside the caller's read tier
            (`leader_not_visible`); or `scope=visible` requested by a non-leader (`leader_access_required`).
            `scope=visible` is the Rounds Log tier, and its web twin gates the whole
            controller on leader access; a non-leader's own rounds are served by `GET
            /leader-rounds/my`, on the subject tier. `scope=mine` stays open to every
            caller.
    post:
      tags:
      - Leader Rounds
      summary: Capture a round
      description: |
        Record a rounding conversation. Delegates to the SAME service the web
        capture form uses (`LeaderRounds::RoundCreator`), so every gate holds
        identically on both doors: the subject must be inside the caller's
        subtree, a self-round is rejected (coverage would be a
        self-attestation), `occurred_on` cannot be in the future, required
        questions must be answered, and an `issue_capture` answer opens a
        tracked row on the stoplight ledger with an owner and a due date.

        **`answers` is keyed by template question id** — get the ids from
        `GET /leader-rounds/templates`. **`issues` is keyed the same way**, on
        the `issue_capture` question that raised each one.

        Saving a round does NOT post a recognition. A `recognition_pick` answer
        records who was picked; the post to their feed is a separate step,
        `POST /leader-rounds/rounds/{id}/recognize`, which is one-shot per
        answer. A round cannot be re-saved.

        **Send an idempotency key.** `round.idempotency_key` is a
        client-generated string, unique per capture attempt. A replay of the
        same key returns the ORIGINAL round with **200** and `replayed: true`
        (not 201), so a client that retries a timed-out create converges
        instead of double-posting. This matters more than a duplicate row: a
        round opens a `Capa::Action` on the stoplight ledger for every issue it
        raised, each owner notified, so one flaky upload would otherwise become
        a duplicate round and phantom issues. Scoped to (business, leader) and
        enforced by a partial unique index, so two concurrent retries of one
        key still yield one round.

        A replay must be the SAME capture: `subject_id` and `template_id` are
        compared, and `occurred_on` when you send it (it defaults to today, so
        a retry after midnight that omits it still replays). The key reused for
        a different capture — or one already used by `POST /rounds/skip` — is
        **409 `idempotency_conflict`**: nothing is saved; send a new key.

        The `Idempotency-Key` header works too, and replays the first answer
        (201, `replayed: false`, with `X-Idempotency-Cached: true`; a replay carries no `unread_notification_count`).
        Either is enough; both may be sent.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - round
              properties:
                round:
                  type: object
                  required:
                  - subject_id
                  - template_id
                  - occurred_on
                  properties:
                    subject_id:
                      type: integer
                      description: Must be inside the caller's reporting subtree,
                        and not the caller.
                      example: 1752
                    template_id:
                      type: integer
                      example: 1
                    occurred_on:
                      type: string
                      format: date
                      description: The date the conversation happened. Never in the
                        future.
                      example: '2026-08-18'
                    duration_minutes:
                      type: integer
                      minimum: 1
                      maximum: 480
                      nullable: true
                      example: 14
                    private_notes:
                      type: string
                      nullable: true
                      description: Leader tier only — never returned to the subject.
                        See the visibility note at the top of this file.
                    idempotency_key:
                      type: string
                      nullable: true
                      maxLength: 255
                      description: 'Client-generated, unique per capture attempt.
                        Replaying the same key for the same capture returns the original
                        round with 200 and `replayed: true` instead of creating a
                        second one; the key reused for a different capture is 409
                        `idempotency_conflict`. Strongly recommended on mobile.'
                      example: a3f1c2e0-9b17-4d55-b0e2-6c8a1f2d4e77
                answers:
                  type: object
                  description: Map of template question id → answer. Populate the
                    field matching the question's type; `value` is accepted for the
                    simple types.
                  additionalProperties:
                    type: object
                    properties:
                      value:
                        description: Scalar answer — string for text/choice, 1..5
                          integer for scale, boolean (or "Yes"/"No", "true"/"false")
                          for yes/no; a yes/no value that names neither is refused
                          with 422.
                        oneOf:
                        - type: string
                        - type: number
                        - type: boolean
                      referenced_user_id:
                        type: integer
                        description: For a `recognition_pick` question — the colleague
                          picked.
                      exclude_from_reporting:
                        type: boolean
                        description: Leave this answer out of reporting — the web
                          and phone forms' "Leave this answer out of themes and reports"
                          box. The answer stays on the round, where its leader and
                          the person rounded on read it, and the Themes tab, AI themes
                          and the engagement score skip it. Honoured for questions
                          whose `excludable_from_reporting` is true (text, scale,
                          yes/no, choice); on any other the answer saves as usual
                          with an `exclusion_not_supported` warning.
                  example:
                    '2':
                      value: Coverage has been steady since the swap flow shipped.
                    '5':
                      value: 4
                issues:
                  type: object
                  description: Map of `issue_capture` question id → the tracked item
                    to open on the stoplight ledger.
                  additionalProperties:
                    type: object
                    required:
                    - description
                    properties:
                      description:
                        type: string
                      assigned_to_id:
                        type: integer
                        nullable: true
                        description: The owner. Never inferred by AI — always an explicit
                          pick.
                      due_date:
                        type: string
                        format: date
                        nullable: true
                      priority:
                        type: string
                        nullable: true
                        enum:
                        - low
                        - medium
                        - high
                        - critical
      responses:
        '201':
          description: Round captured
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                    - type: object
                      properties:
                        substantive:
                          type: boolean
                          description: False for a completed round with every answer
                            blank and no issue (the web's "No answers" badge — coverage
                            does not count it, so the person stays due), and for a
                            draft or a skip.
                        answers_count:
                          type: integer
                          example: 5
                        issues_count:
                          type: integer
                          example: 1
                  replayed:
                    type: boolean
                    description: 'Always false on a 201 — this request created the
                      round. A replay of an earlier `idempotency_key` answers 200
                      with `replayed: true` instead.'
                    example: false
                  warning_details:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsWarningDetail"
                    description: The same warnings as `warnings`, each with a stable
                      `code` a client can branch on and localize, and the question
                      it concerns. Empty when nothing was changed. A client should
                      treat any code it does not recognise as a warning to show, using
                      `message`.
                  warnings:
                    type: array
                    items:
                      type: string
                    description: The round SAVED, but part of the submission did not
                      land as sent — e.g. a recognition pick who is no longer active
                      (no recognition recorded), an issue owner who no longer resolves
                      (the issue falls back to the leader and, because a self-assignment
                      is not notified, nobody is told at all), issues past the 10-per-question
                      ceiling, or a due date that was clamped. None of this is visible
                      in the counts, which all look right — so an empty array is the
                      only "everything landed as sent" signal, and a client should
                      surface any entry to the leader rather than treating the 201/200
                      as the whole answer.
                    example: []
                  warnings_known:
                    type: boolean
                    description: Always true on a 201. On a 200 replay it is false
                      when the first answer's warnings are no longer kept (they are
                      kept 24 hours) — `warnings` then holds one entry saying so (`warnings_not_replayed`),
                      never an empty list.
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '200':
          description: Idempotent replay. The `round.idempotency_key` you sent has
            already been used by this leader for this same capture, so the ORIGINAL
            round is returned and nothing new was created. `replayed` is true. Treat
            this as success. `warnings` repeats the first answer's warnings; if those
            are no longer kept, `warnings_known` is false and `warnings` holds one
            `warnings_not_replayed` entry instead of an empty list.
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                    - type: object
                      properties:
                        substantive:
                          type: boolean
                          description: False for a completed round with every answer
                            blank and no issue (the web's "No answers" badge — coverage
                            does not count it, so the person stays due), and for a
                            draft or a skip.
                        answers_count:
                          type: integer
                          example: 5
                        issues_count:
                          type: integer
                          example: 1
                  replayed:
                    type: boolean
                    example: true
                  warning_details:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsWarningDetail"
                    description: The first answer's warnings, each with a stable `code`
                      a client can branch on and localize, and the question it concerns.
                      Empty only when that answer had none. When they are no longer
                      kept, ONE entry with `code` `warnings_not_replayed` ("This round
                      was already recorded. Any notes about what didn't save were
                      shown the first time.") and `question_id` null.
                  warnings_known:
                    type: boolean
                    description: False when the first answer's warnings are no longer
                      kept, so `warnings` cannot repeat them — it says so instead.
                    example: true
                  warnings:
                    type: array
                    items:
                      type: string
                    description: The round SAVED, but part of the submission did not
                      land as sent — e.g. a recognition pick who is no longer active
                      (no recognition recorded), an issue owner who no longer resolves
                      (the issue falls back to the leader and, because a self-assignment
                      is not notified, nobody is told at all), issues past the 10-per-question
                      ceiling, or a due date that was clamped. None of this is visible
                      in the counts, which all look right — so an empty array is the
                      only "everything landed as sent" signal, and a client should
                      surface any entry to the leader rather than treating the 201/200
                      as the whole answer.
                    example: []
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '422':
          description: Validation failed; `error.code` names which. `invalid_subject`
            — no `subject_id`, an id that is not an active member, one outside the
            caller's reporting subtree, or the caller themself (a self-round); all
            answered identically, so the refusal cannot be used to probe who exists.
            `invalid_template` — `template_id` is not an active template. `invalid_occurred_on`
            — `occurred_on` is not a readable YYYY-MM-DD date (a blank one means today).
            `invalid` — a required question was not answered, a list pick is no longer
            one of its question's choices, or the model rejected a value (a future
            `occurred_on`, a scale value outside 1..5, …), with the reason in `error.message`.
            `error.details.missing_question_ids` and `error.details.stale_choice_question_ids`
            (present only when non-empty) name the template questions to mark.
        '409':
          description: "`idempotency_conflict` — the `round.idempotency_key` (or the
            `Idempotency-Key` header) was already used for a different capture: another
            `subject_id`, `template_id` or `occurred_on`, or a skip. Nothing was saved;
            send a new key. `idempotency_in_progress` — a request with the same `Idempotency-Key`
            header is still running; retry after `Retry-After` seconds to get its
            answer."
  "/leader-rounds/rounds/{id}":
    get:
      tags:
      - Leader Rounds
      summary: One round in full
      description: |
        The whole record of one conversation: every answer in template order,
        the issues it raised, the recognition it posted, and — for the leader
        tier only — the private notes.

        **The tier is the server's job.** Whether `private_notes` is in the
        payload AT ALL is decided here, not by the client hiding a section:
        on a subject's response the key is ABSENT. `private_notes_visible`
        states it positively so a client can DRAW the absence ("your leader's
        private notes aren't shown here") rather than leaving a silent gap.

        `private_notes_visible` is true ONLY for the round's own leader — not
        for a director above them or an app admin, both of whom can read the
        round itself. It tracks whether `private_notes` is in THIS response, not
        whether the caller is inside the notes tier: a flag that said "visible"
        while the key was withheld made an empty card read as "the leader wrote
        none", which is a different claim from "not shown to you".
        Everything else — the same answers, the same issue rows — is identical
        between the two tiers, because it is literally the same record.

        A round id outside the caller's read tier and a nonexistent id both
        return 404 with the same body. See the enumeration note at the top.

        Note an `issue_capture` question produces a LEDGER ROW, not an answer
        row, so `answers` can be shorter than the template's question count
        while `issues` carries the difference.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Round retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                properties:
                  round:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                    - type: object
                      required:
                      - answers
                      - issues
                      - private_notes_visible
                      properties:
                        duration_minutes:
                          type: integer
                          nullable: true
                          example: 14
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                        answers_count:
                          type: integer
                          example: 5
                        issues_count:
                          type: integer
                          example: 1
                        private_notes_visible:
                          type: boolean
                          description: False on the subject tier, where `private_notes`
                            is absent from the payload entirely.
                          example: true
                        answers:
                          type: array
                          description: Ordered by the template's own question order.
                          items:
                            allOf:
                            - "$ref": "#/components/schemas/LeaderRoundsAnswer"
                            - type: object
                              required:
                              - recognition_postable
                              - recognition_refusal
                              properties:
                                recognition_postable:
                                  type: boolean
                                  description: Draw "Post recognition" on this answer
                                    ONLY when true — it is the same rule the web round
                                    page draws its button by, and the one `POST /leader-rounds/rounds/{id}/recognize`
                                    enforces. True only for a recognition pick that
                                    names someone, has not been posted yet, and that
                                    the caller could post now. False for anyone but
                                    the round's own leader (and for a leader who leads
                                    no team today), when the Recognitions app is off
                                    for the tenant, when the pick was already posted,
                                    on every other question type, and when the post
                                    would be refused — in which case `recognition_refusal`
                                    says why.
                                  example: true
                                recognition_refusal:
                                  type: object
                                  nullable: true
                                  description: 'Why this recognition pick cannot be
                                    posted, known before the tap — the sentence the
                                    web prints in place of the button. Null whenever
                                    there is nothing to explain: when `recognition_postable`
                                    is true, and in every case where no button would
                                    be offered at all (not the round''s leader, Recognitions
                                    off, already posted, not a recognition pick).'
                                  required:
                                  - code
                                  - message
                                  properties:
                                    code:
                                      type: string
                                      enum:
                                      - not_round_leader
                                      - not_a_recognition_answer
                                      - no_recognition_recipient
                                      - self_recognition
                                      - recognition_recipient_inactive
                                      - giving_not_allowed
                                      description: Stable, for branching and localizing.
                                        In practice the round-detail read sends `self_recognition`
                                        (the leader picked themself), `recognition_recipient_inactive`
                                        (the person has left), `no_recognition_recipient`
                                        (the person's record no longer exists) or
                                        `giving_not_allowed` (peer recognition is
                                        off for the caller); the rest of the enum
                                        is the same refusal set the recognize endpoint
                                        answers 403/422 with.
                                      example: recognition_recipient_inactive
                                    message:
                                      type: string
                                      description: Render verbatim — the sentence
                                        the web prints.
                                      example: Amy Okonkwo is no longer an active
                                        member here, so this recognition can't be
                                        posted.
                                  example:
                        issues:
                          type: array
                          description: The stoplight rows this round raised, oldest
                            first.
                          items:
                            allOf:
                            - "$ref": "#/components/schemas/LeaderRoundsIssue"
                            - type: object
                              properties:
                                raised_by_can_open:
                                  type: boolean
                                  nullable: true
                                  description: 'Whether the person who raised the
                                    issue — its round''s subject — can open Leader
                                    Rounds. False means a Won''t fix note never reaches
                                    them: warn as the web and phone status forms do
                                    ("<Name> can''t open Leader Rounds, so tell them
                                    another way"). Null when no round is behind the
                                    issue.'
                                  example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Round not found (`not_found`) — returned identically for a
            nonexistent id and for one outside the caller's read tier.
  "/leader-rounds/team":
    get:
      tags:
      - Leader Rounds
      summary: Coverage rollup up the reporting line
      description: |
        The coverage report with layer-by-layer drill-down. **The axis is the
        reporting line, not the location tree.** Every aggregate is batched — no
        per-leader queries.

        Three collections answer three different questions, and mixing them up
        is the usual misreading:
        * `summary` — the totals for the anchored node, including its own direct
          reports.
        * `units` — the layer DIRECTLY BELOW the node, each row aggregating that
          unit's whole subtree. The node's own direct reports sit in `summary`
          and in no unit row; without knowing that, a reader finds a gap between
          the two and concludes one is broken.
        * `leaders` — flat per-leader rows for the subtree, paginated. A
          leader's row counts THEIR OWN direct reports only.

        Ordered worst-first (`[-stuck_issues, coverage]`, then name) so the
        response opens on the problem: `stuck_issues` is open AND overdue, while
        `red_issues` also counts closed won't-fix decisions.

        **Coverage counts SUBSTANTIVE completed rounds only** — blank-answer
        rounds score nothing (guardrail #1). `same_day_cluster` is guardrail #2:
        true when five or more rounds in the window have 80% of them stamped on
        one calendar day. Nine rounds inside twenty-two minutes is a signal a
        director should see, not something to smooth away.

        Ageing is reported as two separate numbers because they answer different
        questions: `oldest_red_days` is days PAST DUE (the same clock the
        escalation job counts on), `oldest_open_days` is days SINCE RAISED.
      security:
      - BearerAuth: []
      parameters:
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: Anchor at one leader in the caller's tree. Blank means no narrowing.
          An id outside the caller's tree is refused (`node_not_visible`, 403) rather
          than clamped — on an API a silently-ignored filter produces a mislabelled
          list.
      - name: window
        in: query
        required: false
        schema:
          type: integer
          default: 30
          minimum: 7
          maximum: 365
        description: Measurement window in days. Out-of-range values fall back to
          30.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Rollup retrieved successfully
          headers:
            X-Total-Count:
              description: Rows in the whole list, across every page.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages at the applied `per_page`.
              schema:
                type: integer
            X-Current-Page:
              description: The page this response holds (1-based).
              schema:
                type: integer
            X-Per-Page:
              description: The page size actually applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - window_days
                - breadcrumb
                - summary
                - units
                - leaders
                - meta
                properties:
                  window_days:
                    type: integer
                    example: 30
                  node:
                    type: integer
                    nullable: true
                    description: The anchored node, or null for the caller's own root.
                  breadcrumb:
                    type: array
                    description: Path from the top of the caller's span down to `node`.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  summary:
                    type: object
                    description: Totals for the anchored node. Includes its own direct
                      reports. Summed over every leader row in scope (all of them,
                      not the current page of `leaders`).
                    properties:
                      leaders:
                        type: integer
                        description: Leader rows in scope.
                        example: 7
                      team_size:
                        type: integer
                        description: People those leaders are responsible for rounding
                          on.
                        example: 58
                      expected:
                        type: integer
                        description: Rounds the window expects by cadence, rounded
                          up — what `coverage_pct` is measured against.
                        example: 52
                      rounded_subjects:
                        type: integer
                        description: People with a SUBSTANTIVE completed round in
                          the window.
                        example: 41
                      skipped_subjects:
                        type: integer
                        description: People skipped in the window, within their cadence,
                          and not rounded. A skip settles the person, so they are
                          left out of `expected` — show the count beside the percentage.
                        example: 2
                      coverage_pct:
                        type: integer
                        minimum: 0
                        maximum: 100
                        description: "`rounded_subjects` over `expected`, capped at
                          100. A literal 0 when nothing was expected — read `coverage_expected`
                          and `coverage_delegated_out` before drawing it as a score."
                        example: 79
                      open_issues:
                        type: integer
                        example: 12
                      red_issues:
                        type: integer
                        example: 3
                      credit_mentions:
                        type: integer
                        description: Credit RAISED in rounds in scope — recognition
                          picks naming someone.
                        example: 9
                      credit_posted:
                        type: integer
                        description: How much of `credit_mentions` actually reached
                          Recognitions.
                        example: 6
                  coverage_expected:
                    type: boolean
                    description: False when the window expected NO rounds anywhere
                      in scope, in which case `summary.coverage_pct` is a literal
                      0 that is not a score. Render "No rounds expected in this window"
                      (the web draws "—") rather than 0%.
                    example: true
                  coverage_delegated_out:
                    type: boolean
                    description: TRUE when EVERY leader row in scope has handed their
                      rounding to someone above them for this window — the same flag
                      the unit and leader rows carry, one level up. `summary.coverage_pct`
                      is then 0 for that reason, not because nobody rounded; render
                      "Rounding delegated for this window" rather than the bar. False
                      when there are no rows at all.
                    example: false
                  credit_shown:
                    type: boolean
                    description: Whether to print credit (`summary.credit_mentions`
                      / `credit_posted` and each leader row's pair). False when Recognitions
                      is off for this company — nothing can be posted there, so the
                      web Team Rollup hides credit.
                    example: true
                  coverage_caption:
                    type: string
                    description: The Team Rollup's caption under the coverage tile,
                      in whole people ("12 of 40 rounded · not all are due this window").
                      Print it beside `summary.coverage_pct` instead of `summary.expected`,
                      which is rounded up.
                    example: 12 of 40 rounded
                  uncovered_reports_total:
                    type: integer
                    description: How many active members report to a manager who is
                      no longer active here. They are counted in no row and in no
                      coverage percentage, so print a note beside the figures ("3
                      people report to a manager who is no longer active here") —
                      the web Team Rollup does. Only a tenant-wide app admin on the
                      unnavigated root is told; 0 for every other view.
                    example: 3
                  uncovered_reports:
                    type: array
                    description: The first five of those people, by first name, to
                      name in that note; count the rest from `uncovered_reports_total`.
                      Empty whenever the total is 0.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 5123
                        name:
                          type: string
                          nullable: true
                          example: Aiden Fenn
                  viewer_is_app_admin:
                    type: boolean
                    description: Whether the caller is a Leader Rounds app admin (or
                      business admin). Their root is the whole account; a leader's
                      is their own reporting line.
                    example: false
                  root_label:
                    type: string
                    enum:
                    - All teams
                    - My org
                    description: 'What to call the scope root — the label the web
                      uses: "All teams" for an app admin, "My org" for a leader.'
                    example: My org
                  at_span_roots:
                    type: boolean
                    description: 'TRUE only for an app admin on the UNNAVIGATED root,
                      where `units` is not an org layer but the UNPARENTED bag — every
                      leader whose manager is unset or is not themselves a leader.
                      It cannot be inferred from `node == null`: a non-admin leader
                      anchored on their own node also has a null node, and for them
                      the units genuinely ARE their reports. A client can use it to
                      explain a long flat list (69 rows on the dev tenant, where 61,122
                      of 62,107 members have no manager set) instead of letting a
                      director read it as their org chart.'
                  units:
                    type: array
                    description: The layer directly below the node; each row aggregates
                      its whole subtree.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          nullable: true
                          description: The node's leader id; null only on the `departed`
                            row.
                        name:
                          type: string
                          example: Priya Raman · 4 East
                        leaf:
                          type: boolean
                          description: No layer below. A leaf row should open the
                            leader detail rather than descending into an empty level.
                        leaders:
                          type: integer
                        team_size:
                          type: integer
                        expected:
                          type: integer
                          description: The window's obligation for this unit's whole
                            subtree, by cadence (rounded up) — what `coverage_pct`
                            is measured against. 0 means nothing was expected; see
                            `coverage_delegated_out` for why that is not always "nobody
                            was due".
                          example: 9
                        rounded_subjects:
                          type: integer
                        coverage_pct:
                          type: number
                          format: float
                        coverage_caption:
                          type: string
                          description: 'The Team Rollup''s caption for this row''s
                            coverage, in whole people ("1 of 5 rounded · 60% of those
                            due"). Where no coverage was expected it is the sentence
                            the web prints instead: "No rounds expected in this window",
                            "Rounding delegated for this window — open issues stay
                            theirs", or, on the `departed` row, "No team in the org
                            chart — coverage not measured". Print this rather than
                            `expected` beside `coverage_pct`: `expected` is rounded
                            up, so the two numbers can read as a contradiction.'
                          example: 1 of 5 rounded · 60% of those due
                        open_issues:
                          type: integer
                        red_issues:
                          type: integer
                          description: Overdue open issues plus closed won't-fix decisions.
                        total_issues:
                          type: integer
                          description: Every issue the rounds raised, any status —
                            the number the web row prints ("N issues · M red") and
                            the ledger's "All (N)" one tap later.
                        stuck_issues:
                          type: integer
                          description: Open and past due — the worst-first sort key.
                        departed:
                          type: boolean
                          description: 'TRUE only on the one row an unnavigated root
                            may carry (an app admin''s: the business; a leader''s:
                            their own line): open issues from rounds led by people
                            who no longer lead a team here, titled "Leaders no longer
                            in the org chart". It has no node to drill into (`id`
                            null, `leaf` true) and no coverage — label it, and don''t
                            read its 0% as a score. Its counts open with GET /leader-rounds/issues?former_leaders=1.'
                          example: false
                        coverage_delegated_out:
                          type: boolean
                          description: 'TRUE when EVERY leader rolled up under this
                            unit has handed their rounding to someone above them for
                            this window — obligation counts under the delegate, and
                            the row survives only to keep the unit''s open ledger
                            visible. It is NOT derivable from the payload: `coverage_pct`
                            is a literal 0 and `expected` a literal 0 in this state,
                            exactly as they are when the unit simply had nobody due,
                            so a client that renders the two the same way accuses
                            a delegated unit of doing nothing. Render the caption,
                            not the bar — "Rounding delegated for this window; open
                            issues stay theirs" versus "No rounds expected in this
                            window".'
                        avatar_url:
                          type: string
                          nullable: true
                          description: The unit's own leader — a unit node IS a person.
                            Null when they are no longer an active member; the row
                            still belongs in the rollup (their team's coverage is
                            real), so a client falls back to initials.
                  leaders:
                    type: array
                    description: Per-leader rows for the subtree, worst first, paginated.
                    items:
                      type: object
                      properties:
                        leader_id:
                          type: integer
                        leader_name:
                          type: string
                        leader_job_title:
                          type: string
                          nullable: true
                          description: The credential, null when the tenant records
                            none.
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute URL — the photo, or a generated initials
                            image when there is none.
                        credit_mentions:
                          type: integer
                          description: Credit RAISED in this leader's rounds — a recognition_pick
                            answer naming someone.
                        credit_posted:
                          type: integer
                          description: How much of `credit_mentions` actually reached
                            Recognitions. A gap means praise is dying in the form,
                            which is why both numbers are published rather than a
                            ratio.
                        team_size:
                          type: integer
                        expected:
                          type: integer
                          description: The window's obligation for this leader by
                            cadence (rounded up) — the number `coverage_pct` is measured
                            against; `team_size` stays for context. 0 means no rounds
                            were expected of them in this window.
                          example: 6
                        rounded_subjects:
                          type: integer
                        coverage_pct:
                          type: number
                          format: float
                        coverage_caption:
                          type: string
                          description: 'The Team Rollup''s caption for this row''s
                            coverage, in whole people ("1 of 5 rounded · 60% of those
                            due"). Where no coverage was expected it is the sentence
                            the web prints instead: "No rounds expected in this window",
                            "Rounding delegated for this window — open issues stay
                            theirs", or, on the `departed` row, "No team in the org
                            chart — coverage not measured". Print this rather than
                            `expected` beside `coverage_pct`: `expected` is rounded
                            up, so the two numbers can read as a contradiction.'
                          example: 1 of 5 rounded · 60% of those due
                        open_issues:
                          type: integer
                        red_issues:
                          type: integer
                          description: Overdue open issues plus closed won't-fix decisions.
                        total_issues:
                          type: integer
                          description: Every issue the rounds raised, any status —
                            the number the web row prints ("N issues · M red") and
                            the ledger's "All (N)" one tap later.
                        stuck_issues:
                          type: integer
                          description: Open and past due — the worst-first sort key.
                        coverage_delegated_out:
                          type: boolean
                          description: 'Same flag as on a unit row, for one leader:
                            their rounding is with someone above them for this window.
                            Their team''s obligation counts under the delegate''s
                            row; this row survives so their standing reds do not vanish
                            from the rollup. Not derivable — see the unit row''s note.'
                        oldest_open_days:
                          type: integer
                          nullable: true
                          description: Days since raised.
                        oldest_red_days:
                          type: integer
                          nullable: true
                          description: Days PAST DUE.
                        same_day_cluster:
                          type: boolean
                          description: Guardrail
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or `node` outside the caller's tree (`node_not_visible`).
  "/leader-rounds/issues":
    get:
      tags:
      - Leader Rounds
      summary: The stoplight issue ledger
      description: |
        Issues raised in rounds inside the caller's READ tier, with their
        derived stoplight colour.

        **Colour is DERIVED, never stored:** green is completed, red is
        cancelled or open-past-due, yellow is the open remainder. A cancelled
        issue shares red's colour but is a decision, not limbo — clients should
        label it "Won't fix" rather than repeating "Red".

        `color` is validated against the enum case-insensitively; anything else
        is an explicit 400. A wrong-case or off-enum value must never return
        every colour while the client believes the list is filtered.

        `node` narrows to one drill-down unit's subtree, composing INSIDE the
        read tier — it can only remove rows, never widen the tier. It is the
        same param the rollup uses, so one applied scope reads identically on
        both surfaces.

        **`tally` counts the WHOLE scope, before `color` is applied** — the web
        ledger's `Stoplight.sql_tally`, as three COUNTs. A paginated client must
        render its colour pills from this and never from the rows it holds:
        counting the fetched array caps every total at `per_page` and drops
        colours that sort past the first page (open-first ordering puts a closed
        won't-fix last, so reds are exactly what goes missing). It is also what
        makes `color` safe to send — the pills keep describing the ledger while
        the list is a filtered, paginated query. `meta.total` is the size of the
        FILTERED query; `tally.total` is the size of the scope.
      security:
      - BearerAuth: []
      parameters:
      - name: color
        in: query
        required: false
        schema:
          type: string
          enum:
          - red
          - yellow
          - green
        description: Case-insensitive. Off-enum values are a 400, not an unfiltered
          list.
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - open
          - closed
        description: '`open` — Open and In progress only (what a rollup card''s "N
          open" chip counts); `closed` — resolved and won''t-fix. Narrows the scope
          BEFORE the tally, so `tally` describes the filtered set; `color` narrows
          within it. Case-insensitive; off-enum values are a 400.'
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: Narrow to one unit's subtree, inside the read tier.
      - name: leader_id
        in: query
        required: false
        schema:
          type: integer
        description: Narrow to ONE leader's rounds — what a per-leader issue count
          on the rollup links to, so the number and the list it opens describe the
          same set. Composes on top of the read tier (it can only remove rows). An
          out-of-tier or nonexistent id is refused IDENTICALLY (403 `leader_not_visible`),
          because a distinguishable refusal is a leader enumerator. `tally` reflects
          this narrowing.
      - name: former_leaders
        in: query
        required: false
        schema:
          type: string
          enum:
          - '1'
        description: "`1` narrows to issues from rounds led by people who are not
          current leaders — the set the Team Rollup's `departed: true` row counts,
          so its numbers open the list they counted. For an app admin that is the
          whole business; for a leader it is the people on their own line who have
          left or no longer lead a team. Any other value is refused with 400 `invalid_former_leaders`,
          and so is `node` beside it (400 `former_leaders_with_node` — a node's leaders
          are all current leaders, so the pair could only ever be empty). `tally`
          reflects this narrowing."
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Ledger retrieved successfully
          headers:
            X-Total-Count:
              description: Rows in the whole list, across every page.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages at the applied `per_page`.
              schema:
                type: integer
            X-Current-Page:
              description: The page this response holds (1-based).
              schema:
                type: integer
            X-Per-Page:
              description: The page size actually applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - issues
                - tally
                - meta
                properties:
                  issues:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaderRoundsIssue"
                      - type: object
                        properties:
                          raised_by_can_open:
                            type: boolean
                            nullable: true
                            description: 'Whether the person who raised the issue
                              — its round''s subject — can open Leader Rounds. False
                              means a Won''t fix note never reaches them: warn as
                              the web and phone status forms do ("<Name> can''t open
                              Leader Rounds, so tell them another way"). Null when
                              no round is behind the issue.'
                            example: true
                  tally:
                    type: object
                    description: Stoplight counts over the whole scope — after `status`
                      when one is sent — independent of `color` and of pagination.
                      `total` is their sum — the colours are exhaustive and disjoint
                      over the status set, so a separate COUNT could only disagree
                      with them.
                    required:
                    - red
                    - yellow
                    - green
                    - total
                    properties:
                      red:
                        type: integer
                        example: 1
                      yellow:
                        type: integer
                        example: 7
                      green:
                        type: integer
                        example: 4
                      total:
                        type: integer
                        example: 12
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  viewer_is_app_admin:
                    type: boolean
                    description: Whether the caller is a Leader Rounds app admin (or
                      business admin). Their root is the whole account; a leader's
                      is their own reporting line.
                    example: false
                  root_label:
                    type: string
                    enum:
                    - All teams
                    - My org
                    description: 'What to call the scope root — the label the web
                      uses: "All teams" for an app admin, "My org" for a leader.'
                    example: My org
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: "`color` outside the enum (`invalid_color`), or `status` outside
            the enum (`invalid_status`). Both are matched case-insensitively; an off-enum
            value is refused rather than silently ignored."
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, `node` not visible (`node_not_visible`),
            or `leader_id` outside the caller's read tier (`leader_not_visible`).
  "/leader-rounds/issues/{id}":
    get:
      tags:
      - Leader Rounds
      summary: One issue, with its transition history
      description: |
        The single-issue read that makes an issue DETAIL possible.

        `history` is deliberately absent from the list endpoints — it is a query
        per row — so a detail screen rendered from a list row could never show
        the trail. It existed on exactly one response (the status change), which
        meant the history was visible only in the seconds after the caller
        themselves changed something.

        It arrives as an ENVELOPE key (`history`), a sibling of `issue`, NOT
        nested inside it — the same shape the status write emits. A client that
        reads it off the issue object finds nothing and renders an empty trail
        with no error.

        **The read tier here is WIDER than the ledger's, deliberately.** An issue
        ASSIGNED to the caller from a round they cannot see is precisely the case
        the "Assigned to you" surface exists for — a non-leader has no visible
        rounds at all, so scoping this to visible rounds alone would 404 the one
        person the screen serves. Readable = from a round the caller can see, OR
        assigned to the caller. Neither grants a WRITE: that stays the manageable
        set, and `read_only` on the response states which side the caller is on.

        A nonexistent id and an out-of-tier id are refused IDENTICALLY (404
        `not_found`), because a distinguishable refusal is an issue enumerator.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The issue plus its `history`; the issue carries `read_only`
            and `round_visible`
          content:
            application/json:
              schema:
                type: object
                required:
                - issue
                - history
                properties:
                  issue:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsIssue"
                    - type: object
                      properties:
                        raised_by_can_open:
                          type: boolean
                          nullable: true
                          description: 'Whether the person who raised the issue —
                            its round''s subject — can open Leader Rounds. False means
                            a Won''t fix note never reaches them: warn as the web
                            and phone status forms do ("<Name> can''t open Leader
                            Rounds, so tell them another way"). Null when no round
                            is behind the issue.'
                          example: true
                  history:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsIssueHistory"
                    - type: array
                      items:
                        type: object
                        required:
                        - system_generated
                        properties:
                          system_generated:
                            type: boolean
                            description: 'True for an AUTOMATIC note — today the red-escalation
                              note ("Escalated automatically after N days…"). It is
                              stored under the round''s leader because a note needs
                              an author, but nobody wrote it: on such an entry `author`
                              is the literal "Automatic update" and `author_avatar_url`
                              is null. Draw it as a system entry with no face, as
                              the web trail does — never as words the leader wrote.'
                            example: false
                          author:
                            type: string
                            nullable: true
                            description: The author's name, or the literal "Automatic
                              update" when `system_generated` is true.
                            example: Priya Raman
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not visible to the caller, or no such issue — the two are indistinguishable
            by design (`not_found`).
    patch:
      tags:
      - Leader Rounds
      summary: Reassign an issue, or move its due date / priority
      description: |
        The issue's OWNER, DUE DATE and PRIORITY — everything about an issue
        except its status, which has its own endpoint because only that one
        carries the won't-fix reason gate.

        Delegates to `LeaderRounds::IssueDetailsUpdater`, the same service the
        web panel uses, so three rules hold on both doors: a due date is
        validated (not silently clamped, unlike at creation), the audit note and
        the field change are written in one transaction, and a request that
        changes NOTHING is not an event — it returns `changed: false` rather
        than logging a note saying nothing happened and notifying the "new"
        owner who is the old owner.

        **WRITE TIER.** Being the issue's owner does not let you reassign it;
        being the round's subject does not either. Outside the tier is 404,
        identical to a nonexistent id.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Send only what is changing — an omitted field is left alone.
                TOP-LEVEL keys; unlike the web form, this endpoint does not accept
                them wrapped in `issue`.
              properties:
                assigned_to_id:
                  type: integer
                  description: The new owner. Must be an active member of the business.
                due_date:
                  type: string
                  format: date
                  nullable: true
                priority:
                  type: string
                  enum:
                  - low
                  - medium
                  - high
                  - critical
                pillar_id:
                  type: string
                  nullable: true
                  description: 'Recategorize the issue under one of the tenant''s
                    pillars, overriding the pillar derived from the question that
                    raised it. Three values matter: a pillar id PINS that pillar;
                    `"0"` or `null` pins **Unaligned** (an explicit "this belongs
                    to no pillar", which outranks the question''s own mapping); an
                    empty string `""` CLEARS the override so the issue falls back
                    to whatever the raising question derives. Leave the key out to
                    leave the pillar alone. An id belonging to another tenant is refused,
                    not ignored. Read the result back from the response''s `pillar_id`
                    / `pillar_name`.'
                  example: '4'
      responses:
        '200':
          description: Applied. `changed` is false when the payload matched what was
            already stored — no audit note was written and nobody was notified. The
            issue carries `read_only` and `round_visible`, because a detail screen
            folds this reply back into the record it is displaying.
          content:
            application/json:
              schema:
                type: object
                required:
                - issue
                - changed
                properties:
                  issue:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsIssue"
                    - type: object
                      properties:
                        raised_by_can_open:
                          type: boolean
                          nullable: true
                          description: 'Whether the person who raised the issue —
                            its round''s subject — can open Leader Rounds. False means
                            a Won''t fix note never reaches them: warn as the web
                            and phone status forms do ("<Name> can''t open Leader
                            Rounds, so tell them another way"). Null when no round
                            is behind the issue.'
                          example: true
                  changed:
                    type: boolean
                    example: true
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Saved, with something to know — e.g. the new owner
                      can't open Leader Rounds, so they weren't told and can't open
                      the issue. Show it beside the success message.
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a leader (`leader_access_required`) — as on the web, only
            someone who leads a team today writes, so leading this round once is not
            enough — or app not accessible.
        '404':
          description: Not on the caller's manageable ledger, or no such issue.
        '409':
          description: 'Idempotency-Key header: the key was already used with a different
            request body (`idempotency_conflict` — send a new key), or a request with
            this key is still running (`idempotency_in_progress` — retry after `Retry-After`
            seconds to get its answer).'
        '422':
          description: An unusable owner, due date, priority or pillar (`invalid`),
            with the reasons joined in `error.message` and, field by field, in `error.details.errors_by_field`
            so a form can mark the failing control.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - invalid
                        example: invalid
                      message:
                        type: string
                        example: A due date in the past would show the issue as red
                          immediately — pick today or later.
                      details:
                        type: object
                        nullable: true
                        properties:
                          errors_by_field:
                            type: object
                            description: Keyed by the request field the message belongs
                              to — `assigned_to_id`, `due_date`, `priority`, `pillar_id`
                              — each an array of messages. Only the failing fields
                              appear. Every field is checked, so several can fail
                              at once, and nothing is applied when any does.
                            properties:
                              assigned_to_id:
                                type: array
                                items:
                                  type: string
                              due_date:
                                type: array
                                items:
                                  type: string
                              priority:
                                type: array
                                items:
                                  type: string
                              pillar_id:
                                type: array
                                items:
                                  type: string
                            example:
                              due_date:
                              - A due date in the past would show the issue as red
                                immediately — pick today or later.
  "/leader-rounds/issues/{id}/comments":
    post:
      tags:
      - Leader Rounds
      summary: Post a progress note on an issue
      description: |
        A note WITHOUT a status change — the thread on an issue.

        **READ tier, deliberately.** The person who raised an issue must be able
        to answer on it and so must the owner it was handed to; a thread only
        the ledger's leader can write to is a broadcast, not a conversation. The
        status itself still moves only on the write tier.

        The other party is notified (a leader's note reaches the raiser, a
        reply reaches the owner), never the author, through the same deduped
        in-app "leader_rounds" category as every other notification this app
        sends. A note that landed is never reported as a failure because the
        notify path raised.

        Bodies are truncated at 5,000 characters. @mentions of business members
        are resolved.

        **Send an `Idempotency-Key`.** A retry after a lost response otherwise
        posts the note — and notifies the other party — a second time.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 5000
                  description: 'The note. Whitespace-only is rejected. Longer text
                    is not refused: it is cut to its first 5,000 characters, as on
                    the web.'
      responses:
        '200':
          description: The stored note
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    type: object
                    properties:
                      id:
                        type: integer
                      author:
                        type: string
                      author_avatar_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                      created_at:
                        type: string
                        format: date-time
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not readable by the caller, or no such issue.
        '409':
          description: 'Idempotency-Key header: the key was already used with a different
            request body (`idempotency_conflict` — send a new key), or a request with
            this key is still running (`idempotency_in_progress` — retry after `Retry-After`
            seconds to get its answer).'
        '422':
          description: A blank body, or the note could not be stored (`invalid`).
  "/leader-rounds/issues/{id}/status":
    patch:
      tags:
      - Leader Rounds
      summary: Update an issue's status
      description: |
        Move an issue on the stoplight ledger. Delegates to the same service the
        web ledger uses (`LeaderRounds::IssueStatusUpdater`), so the guardrails
        hold identically:

        * **Cancelling requires a reason, and the reason is PUBLISHED to the
          person who raised it.** This is what keeps the ledger honest — a
          leader may say no, but not silently. A cancel without
          `resolution_notes` is 422 `reason_required`.
        * A closed issue stays reopenable, so a misclicked "resolved" is
          recoverable.
        * The transition writes an audit note atomically with the status.
        * **Token scope:** `write:leader_rounds`, and `destructive:leader_rounds`
          as well for `status: cancelled` (Won't fix — a final act, #6721). A
          token without them is 403 `insufficient_permissions`.

        **WRITE TIER, not read tier.** Being the SUBJECT of the round an issue
        came from grants read, never write; likewise being the issue's OWNER
        does not grant a status write — the status belongs to the leader whose
        ledger it is, so the person who raised it always hears back from their
        own leader. An issue outside the write tier is 404, identical to a
        nonexistent one. And only a LEADER writes (403 `leader_access_required`
        otherwise): someone who led rounds once and leads no team now is refused,
        as on the web ledger.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  description: The target status on the CAPA register. Validated against
                    `Capa::Action::VALID_STATUSES` — anything else is a 400.
                  enum:
                  - pending
                  - in_progress
                  - completed
                  - cancelled
                  example: completed
                resolution_notes:
                  type: string
                  nullable: true
                  maxLength: 4000
                  description: REQUIRED when cancelling. Published to the raiser —
                    write it for them to read, not for the audit log. Over 4,000 characters
                    (after trimming) is refused with 422 `reason_too_long` on any
                    status, not cut.
      responses:
        '200':
          description: 'Status updated; the reply carries the refreshed `history`,
            and the issue carries `read_only` and `round_visible` — a detail screen
            folds this reply back into the record it is displaying, so a reply that
            omitted them made the status control and the link to the round disappear
            the instant the write succeeded. `changed` is false when the issue already
            had that status: nothing was written, no audit note was added and nobody
            was notified.'
          content:
            application/json:
              schema:
                type: object
                required:
                - issue
                - changed
                - history
                properties:
                  issue:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsIssue"
                    - type: object
                      properties:
                        raised_by_can_open:
                          type: boolean
                          nullable: true
                          description: 'Whether the person who raised the issue —
                            its round''s subject — can open Leader Rounds. False means
                            a Won''t fix note never reaches them: warn as the web
                            and phone status forms do ("<Name> can''t open Leader
                            Rounds, so tell them another way"). Null when no round
                            is behind the issue.'
                          example: true
                  changed:
                    type: boolean
                    description: False when the issue already had this status — nothing
                      was written.
                    example: true
                  history:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsIssueHistory"
                    - type: array
                      items:
                        type: object
                        required:
                        - system_generated
                        properties:
                          system_generated:
                            type: boolean
                            description: 'True for an AUTOMATIC note — today the red-escalation
                              note ("Escalated automatically after N days…"). It is
                              stored under the round''s leader because a note needs
                              an author, but nobody wrote it: on such an entry `author`
                              is the literal "Automatic update" and `author_avatar_url`
                              is null. Draw it as a system entry with no face, as
                              the web trail does — never as words the leader wrote.'
                            example: false
                          author:
                            type: string
                            nullable: true
                            description: The author's name, or the literal "Automatic
                              update" when `system_generated` is true.
                            example: Priya Raman
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '400':
          description: An unusable target status (`invalid_status`) — `status` missing
            or not one of the enum values. This is the ONLY 400 here; every other
            refusal is a 422.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a leader (`leader_access_required`) — as on the web, only
            someone who leads a team today writes, so leading this round once is not
            enough — or app not accessible.
        '404':
          description: Issue not on the caller's manageable ledger — returned identically
            for a nonexistent id and one outside the write tier.
        '409':
          description: 'Idempotency-Key header: the key was already used with a different
            request body (`idempotency_conflict` — send a new key), or a request with
            this key is still running (`idempotency_in_progress` — retry after `Retry-After`
            seconds to get its answer).'
        '422':
          description: Cancelling without `resolution_notes` (`reason_required`);
            `resolution_notes` over 4,000 characters on any status (`reason_too_long`
            — it is refused, not cut, because it is published to the raiser); or the
            model refused the write (`invalid`, with the reason in `error.message`).
  "/leader-rounds/my":
    get:
      tags:
      - Leader Rounds
      summary: Rounds about me, what I raised, and what I own
      description: |
        The subject tier — the close-the-loop surface, and the answer to "what
        happened to what I said". Three collections, in the order the web
        renders them:

        * `assigned_issues` — work handed to you from someone else's round.
          **Being the assignee grants no write**: the status belongs to the
          leader whose ledger it is, so each row carries `read_only`, computed
          per caller (see below) — true unless you also manage the round it
          came from. Each row names who
          RAISED it, which is the one thing a bare ledger row cannot tell an
          owner. This is the entire issue-owner persona: at rollout a tenant
          enables Leader Rounds for nursing first, and support departments own
          issues long before anyone rounds on them — so an owner commonly has
          three assigned issues and two empty sections.
        * `issues` — the issues YOU raised, with their current colour. This
          visibility is the whole point: an issue ledger the workforce cannot
          see is a notebook, not a rounding program.
        * `rounds` — the rounds your leader logged about you: completed rounds
          AND skips (a skip's written reason is yours to read — `status` is
          `skipped` and `skipped_reason` carries it). Drafts are not included:
          a draft is a capture the leader has not finished, and neither are its
          issues. Subject tier, so `private_notes` is absent from every row.

        **Empty sections are answered honestly.** A department that has not
        adopted rounding gets genuinely empty `rounds` and `issues`; clients
        should say so plainly rather than painting a green tick.

        **Three collections means three cursors.** `page` walks `rounds`,
        `issues_page` walks `issues`, `assigned_page` walks `assigned_issues`,
        and each carries its own meta — so paging one never disturbs the others.
        `meta` stays bound to `rounds` (the envelope-wide key every other action
        here emits); `issues_meta` and `assigned_issues_meta` are its twins. All
        three honour `per_page`.

        **`assigned_issues` holds work handed to the caller from rounds they were
        NOT the subject of.** Rounds about the caller are excluded because those
        issues already appear in `issues` — listing them in both reads as two
        separate items. Each row carries `read_only`, computed per caller: false
        only when the caller is a leader today AND leads (or supervises the
        leader of) the round it came from, in which case the honest UI points
        them at the ledger rather than claiming somebody else owns it.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
        description: Cursor for `rounds`.
      - name: issues_page
        in: query
        required: false
        schema:
          type: integer
          default: 1
        description: Cursor for `issues` (what you raised).
      - name: assigned_page
        in: query
        required: false
        schema:
          type: integer
          default: 1
        description: Cursor for `assigned_issues` (what you own).
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
        description: Shared by all three collections.
      responses:
        '200':
          description: Subject-tier view retrieved successfully
          headers:
            X-Total-Count:
              description: Rounds about the caller, across every page — describes
                `meta` (the `rounds` collection) only.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages of `rounds` at the applied `per_page`.
              schema:
                type: integer
            X-Current-Page:
              description: The `rounds` page this response holds (the `page` cursor).
              schema:
                type: integer
            X-Per-Page:
              description: The page size actually applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - rounds
                - issues
                - issues_tally
                - assigned_issues
                - meta
                - issues_meta
                - assigned_issues_meta
                properties:
                  rounds:
                    type: array
                    description: Completed and skipped rounds about the caller (never
                      drafts). Subject tier — no private notes.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                      - type: object
                        properties:
                          substantive:
                            type: boolean
                            description: False for a completed round with every answer
                              blank and no issue (the web's "No answers" badge — coverage
                              does not count it, so the person stays due), and for
                              a draft or a skip.
                  issues:
                    type: array
                    description: Issues raised in rounds about the caller (drafts'
                      issues excluded), open first.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaderRoundsIssue"
                      - type: object
                        properties:
                          raised_by_can_open:
                            type: boolean
                            nullable: true
                            description: 'Whether the person who raised the issue
                              — its round''s subject — can open Leader Rounds. False
                              means a Won''t fix note never reaches them: warn as
                              the web and phone status forms do ("<Name> can''t open
                              Leader Rounds, so tell them another way"). Null when
                              no round is behind the issue.'
                            example: true
                  issues_tally:
                    type: object
                    description: The stoplight split over everything the caller has
                      RAISED, counted over the WHOLE set rather than the page — a
                      client that counted `issues` would describe 25 rows, not the
                      ledger. Same contract as `tally` on /leader-rounds/issues.
                    required:
                    - red
                    - yellow
                    - green
                    - total
                    properties:
                      red:
                        type: integer
                      yellow:
                        type: integer
                      green:
                        type: integer
                      total:
                        type: integer
                  assigned_issues:
                    type: array
                    description: Issues assigned to the caller from rounds they were
                      NOT the subject of (those are already in `issues`), open first.
                      Whether the caller may move each one is the row's own `read_only`.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaderRoundsIssue"
                      - type: object
                        properties:
                          raised_by_can_open:
                            type: boolean
                            nullable: true
                            description: 'Whether the person who raised the issue
                              — its round''s subject — can open Leader Rounds. False
                              means a Won''t fix note never reaches them: warn as
                              the web and phone status forms do ("<Name> can''t open
                              Leader Rounds, so tell them another way"). Null when
                              no round is behind the issue.'
                            example: true
                      - type: object
                        properties:
                          raised_by_name:
                            type: string
                            nullable: true
                            description: The subject of the round this issue came
                              from — who asked for it.
                            example: Marcus Bell
                          read_only:
                            type: boolean
                            description: True unless the caller may move this issue
                              — a leader today who leads, or supervises the leader
                              of, its round. Being the assignee grants nothing. A
                              client must not render a status control when true.
                            example: true
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  issues_meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  assigned_issues_meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  team_conversations_waiting:
                    type: array
                    description: 'Team conversations this person is invited to and
                      has not taken part in (the web''s Take Part cards): who asked
                      and the last day, never who else was asked or has answered.
                      Open `take_path` in the web view to take part.'
                    items:
                      type: object
                      properties:
                        team_listen_id:
                          type: integer
                        asked_by:
                          type: string
                        open_until:
                          type: string
                          format: date
                        take_path:
                          type: string
                          example: "/apps/leader-rounds/team-listens/12/take"
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
  "/leader-rounds/rounds/skip":
    post:
      tags:
      - Leader Rounds
      summary: Record a skip
      description: |
        Record an untaken round as a **deliberate decision** rather than a gap
        ("subject on leave"). Delegates to `LeaderRounds::RoundSkipper`, the same
        service the web's skip action calls.

        A skip **suppresses the obligation without pretending the person was
        rounded on**: the row stops being due, while `/due` still reports
        `never_rounded: true` with no `last_rounded_on` and a populated
        `last_skipped_on`. The skip itself, with its reason, surfaces in the
        leader's recent-rounds history.

        **The reason is required** — a skip with no reason is a gap, not a
        decision, and the subject can read it when they can open Leader Rounds
        (`LeaderRounds::Access.subject_can_open?`). The subject must be inside the
        caller's reporting subtree (the same scope the capture picker uses), so
        a skip can never be filed against someone the create gate would reject.

        The template recorded against the skip is the industry-NEUTRAL staff
        template, never the ordering-first row: a skip filed against
        "New Hire 30-60-90" reads on the history row as a new-hire round for a
        ten-year veteran.

        **Send an idempotency key.** `idempotency_key` (top level, beside
        `subject_id`) works as `round.idempotency_key` does on
        `POST /leader-rounds/rounds`, in the same key space: a retry with the
        same key for the same person returns the ORIGINAL skip with **200** and
        `replayed: true`; the key already used for a round, or for a skip of
        someone else, is **409 `idempotency_conflict`** and nothing is saved.
        The `Idempotency-Key` header works too, and replays the first answer
        verbatim.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subject_id
              - skipped_reason
              properties:
                subject_id:
                  type: integer
                  description: Must be inside the caller's reporting subtree.
                  example: 1752
                skipped_reason:
                  type: string
                  description: Required. Recorded, and readable by the subject when
                    they can open Leader Rounds.
                  example: On leave until September.
                idempotency_key:
                  type: string
                  nullable: true
                  maxLength: 255
                  description: 'Client-generated, unique per skip attempt. A retry
                    with the same key returns the original skip with 200 and `replayed:
                    true` instead of filing a second one.'
                  example: 5d0c9a7e-2f41-4b8e-9a51-0c7f3e6b2d18
      responses:
        '201':
          description: Skip recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                - replayed
                properties:
                  round:
                    "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                  replayed:
                    type: boolean
                    description: Always false on a 201 — this request recorded the
                      skip.
                    example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '200':
          description: 'Idempotent replay — the `idempotency_key` was already used
            for this skip of this person, so the ORIGINAL skip is returned with `replayed:
            true` and nothing new was recorded. Treat as success.'
          content:
            application/json:
              schema:
                type: object
                required:
                - round
                - replayed
                properties:
                  round:
                    "$ref": "#/components/schemas/LeaderRoundsRoundSummary"
                  replayed:
                    type: boolean
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '403':
          description: App not accessible, or the subject is outside the caller's
            subtree (`invalid_subject`).
        '409':
          description: "`idempotency_conflict` — the `idempotency_key` (or the `Idempotency-Key`
            header) was already used for a round, or for a skip of someone else. Nothing
            was saved; send a new key. `idempotency_in_progress` — a request with
            the same `Idempotency-Key` header is still running; retry after `Retry-After`
            seconds."
        '422':
          description: Missing reason (`reason_required`), no active template (`no_active_template`),
            or a model validation failure (`invalid`).
  "/leader-rounds/rounds/{id}/recognize":
    post:
      tags:
      - Leader Rounds
      summary: Post a round's recognition to Recognitions
      description: |
        Publish the round's `recognition_pick` answer to the named colleague's
        feed. Delegates to `LeaderRounds::RecognitionPoster`, shared with the
        web, which routes through `Recognition::PeerPostCreator` — the canonical
        entrypoint carrying every give-gate (peer access, governance,
        moderation, approval routing). Never posts to a feed directly.

        **ONE-SHOT BY CONSTRUCTION.** An already-posted answer returns **200
        with `already_posted: true`**, not an error — the whole point of the
        dedupe state is that a retry converges instead of spamming the
        recipient. `approval_status` says where the post landed, in the words
        every give door uses: `active` (published), `posting` (held for the
        automatic content check — not live yet), `pending_review` (a moderator
        decides) or `pending_approval` (the recipient's manager approves).
        It is null on an `already_posted` reply. A second request for the
        same answer while one is posting is 409 `recognition_in_progress` —
        retry it to get the result; it never posts twice.

        **Only the leader who held the round may post its recognition** — the
        post carries their name, so a manager in the chain posting on their
        behalf would misattribute the credit.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - answer_id
              properties:
                answer_id:
                  type: integer
                  description: The round's `recognition_pick` answer.
                  example: 18
      responses:
        '200':
          description: Posted, or already posted
          content:
            application/json:
              schema:
                type: object
                required:
                - already_posted
                - answer
                properties:
                  already_posted:
                    type: boolean
                    description: True when a prior post already exists — not an error.
                    example: false
                  approval_status:
                    type: string
                    nullable: true
                    enum:
                    - active
                    - posting
                    - pending_review
                    - pending_approval
                    -
                    description: 'Where the post landed: `active` (published), `posting`
                      (held for the automatic content check — not live yet), `pending_review`
                      (a moderator decides), `pending_approval` (the recipient''s
                      manager approves). Null when `already_posted` is true.'
                    example: active
                  recipient_name:
                    type: string
                    nullable: true
                    example: Amy Okonkwo
                  warnings:
                    type: array
                    items:
                      type: string
                    description: The give SUCCEEDED but something around it did not
                      — most importantly the one-shot marker may not have been written,
                      so a client that retries on an empty `warnings` list would double-post
                      to the recipient's feed. Treat a non-empty array as "posted,
                      do not retry, tell the leader".
                    example: []
                  warning_details:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsWarningDetail"
                    description: The same warnings as `warnings`, each with a stable
                      `code` a client can branch on and localize, and the question
                      it concerns. Empty when nothing went wrong around the give.
                  answer:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsAnswer"
                    - type: object
                      description: 'The answer as it stands after the post. It carries
                        the same `recognition_postable` / `recognition_refusal` pair
                        as the round-detail read, but here they are always `false`
                        / null: the pick has just been posted (or was already), so
                        there is nothing left to offer — read `recognition_posted`
                        and `recognition_status`.'
                      properties:
                        recognition_postable:
                          type: boolean
                          description: Always false on this response.
                          example: false
                        recognition_refusal:
                          type: object
                          nullable: true
                          description: Always null on this response. Same shape as
                            on `GET /leader-rounds/rounds/{id}` ({code, message}).
                          properties:
                            code:
                              type: string
                            message:
                              type: string
                          example:
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'Not a leader today (`leader_access_required` — the web gate;
            leading this round once is not enough), not the round''s leader (`not_round_leader`),
            the Recognitions app this posts into is off for the organization (`recognitions_disabled`
            — the round detail then reports `recognition_postable: false` on every
            answer), or app not accessible.'
        '404':
          description: Round not found or outside the caller's read tier (identical
            bodies).
        '409':
          description: "`recognition_in_progress` — another request is posting this
            same answer right now; retry to get its result (it is never posted twice).
            Or, for the `Idempotency-Key` header, `idempotency_conflict` (the key
            was used with a different body — send a new key) / `idempotency_in_progress`
            (retry after `Retry-After` seconds)."
        '422':
          description: 'The `answer_id` is not part of this round (`answer_not_found`
            — note this takes the ANSWER''s `id`, not its `question_id`), the question
            does not collect a recognition (`not_a_recognition_answer`), the answer
            names nobody (`no_recognition_recipient`), the leader picked themself
            (`self_recognition` — a recognition has to go to someone else), the person
            named is no longer an active member of the organization (`recognition_recipient_inactive`),
            peer recognition is not enabled for the caller (`giving_not_allowed`),
            or the Recognitions app refused the post (`recognition_failed`, with its
            reason in `error.message`). Every refusal except `answer_not_found` and
            `recognition_failed` is known before the tap: `GET /leader-rounds/rounds/{id}`
            reports it per answer as `recognition_refusal`, so a client that draws
            the button only on `recognition_postable` should not meet them.'
  "/leader-rounds/rounds/subject-search":
    get:
      tags:
      - Leader Rounds
      summary: Search the people the caller may round on
      description: |
        The capture form's person typeahead, and the picker behind "round on
        someone outside my due list".

        **SCOPE IS THE AUTHORIZATION.** Results are the caller's reporting
        subtree ∩ active members — the same scope `RoundCreator` and
        `RoundSkipper` resolve against, so the picker and the create gate cannot
        disagree. A global user search would offer people the gate then rejects,
        which reads to the user as the app losing their round.

        A blank `q` returns the first page of that scope, so the picker can open
        populated rather than empty. Page with `page` until `meta.more` is
        false — a subtree larger than `per_page` is otherwise unreachable.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: 'Case-insensitive substring match against first name, last name,
          the two joined as "First Last", display name, preferred name, email and
          job title. Because job title is matched, a query like `RN` returns everyone
          holding that title, not only people whose name contains it. The joined form
          is one-directional: `Dana Whitfield` matches, `Whitfield Dana` does not.'
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
          minimum: 1
        description: 1-based page cursor. Results are ordered by first name, last
          name, id.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
        description: Page size. A blank, zero, negative or non-numeric value falls
          back to the default rather than to the minimum.
      responses:
        '200':
          description: Candidate subjects
          content:
            application/json:
              schema:
                type: object
                required:
                - subjects
                - meta
                properties:
                  subjects:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 1752
                        name:
                          type: string
                          example: Marcus Bell
                        email:
                          type: string
                          nullable: true
                          description: Null for a member with no email address — most
                            frontline members have none, so a client must not rely
                            on it to identify or label a person.
                          example: marcus@example.com
                        job_title:
                          type: string
                          nullable: true
                          description: Null when the tenant records none. Present
                            so a round opened from this picker identifies its subject
                            the same way one opened from /due does.
                          example: RN
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute URL — the photo, or a generated initials
                            image when there is none.
                          example: https://acme.workforce.mangoapps.com/rails/active_storage/…
                        template_id:
                          type: integer
                          nullable: true
                          description: 'The template a round with this person opens
                            on — as on /due: their Rounding Program''s, else the account
                            default.'
                        program_name:
                          type: string
                          nullable: true
                          description: The Rounding Program that applies to this person,
                            null when none.
                  meta:
                    type: object
                    description: 'Cursor state. `more` is true when this page came
                      back full, i.e. another page is plausibly available. There is
                      deliberately no total — counting the whole subtree on every
                      keystroke is what this endpoint exists to avoid. `current_page`,
                      `has_next_page` and `has_prev_page` are the base API''s standard
                      names, sent beside the originals so a client''s shared pager
                      reads this list too: `current_page` equals `page` and `has_next_page`
                      equals `more`. There is no `total_count` / `total_pages`, for
                      the reason above.'
                    required:
                    - page
                    - per_page
                    - more
                    - current_page
                    - has_next_page
                    - has_prev_page
                    properties:
                      page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 25
                      more:
                        type: boolean
                        example: true
                      current_page:
                        type: integer
                        example: 1
                      has_next_page:
                        type: boolean
                        description: Same value as `more`.
                        example: true
                      has_prev_page:
                        type: boolean
                        description: True on any page after the first.
                        example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
  "/leader-rounds/rounds/pause-reminders":
    post:
      tags:
      - Leader Rounds
      summary: Pause the weekly due-rounds digest
      description: |
        The leader's own off switch for the Monday digest, for four weeks.
        Delegates to `LeaderRounds::DueReminderPreference`, shared with the web.

        **A SNOOZE, NEVER A DISMISS.** `NotificationDelivery#dismiss!` is
        permanent and has no inverse, so wiring a user-facing pause to it would
        be a one-way trap. `resume-reminders` is the real inverse.

        `reminders_enabled` reflects the TENANT-level setting — there is no point
        offering a personal pause for a digest the whole account has turned off,
        so a client should read it before showing the control. It is NOT the
        personal pause and reads `true` on both sides of one: read `paused` for
        that.

        **The body reports what was written, read back from the ledger** — not
        what the request intended. The same three fields are on
        `GET /leader-rounds/due` under `reminders`, so a client can show the
        state before anyone taps.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Paused
          content:
            application/json:
              schema:
                type: object
                required:
                - reminders_enabled
                - paused
                properties:
                  reminders_enabled:
                    type: boolean
                    description: The tenant-level digest setting, not the personal
                      pause.
                    example: true
                  paused:
                    type: boolean
                    description: The PERSONAL pause, observed after the write. This
                      is the key that distinguishes a paused leader from an unpaused
                      one — `reminders_enabled` cannot, because it answers a different
                      question.
                    example: true
                  paused_until:
                    type: string
                    format: date-time
                    nullable: true
                    description: When the snooze lifts, re-read from the ledger after
                      the write. Null means not currently paused — a snooze already
                      in the past is not a pause.
                    example: '2026-09-15T09:29:55.371Z'
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a leader (`leader_access_required`) — the digest is a leader's,
            as on the web — or app not accessible.
  "/leader-rounds/rounds/resume-reminders":
    post:
      tags:
      - Leader Rounds
      summary: Resume the weekly due-rounds digest
      description: |
        The inverse of `pause-reminders` — clears the snooze. Like `pause`, the
        body reports the state READ BACK from the ledger rather than the state the
        request intended, so a successful resume answers `paused: false` and
        `paused_until: null` because that is what the ledger now says.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Resumed
          content:
            application/json:
              schema:
                type: object
                required:
                - reminders_enabled
                - paused
                properties:
                  reminders_enabled:
                    type: boolean
                    example: true
                  paused:
                    type: boolean
                    description: The personal pause, observed after the write.
                    example: false
                  paused_until:
                    type: string
                    format: date-time
                    nullable: true
                    example:
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Not a leader (`leader_access_required`) — the digest is a leader's,
            as on the web — or app not accessible.
  "/leader-rounds/rounds/recap":
    post:
      tags:
      - Leader Rounds
      summary: Turn a spoken recap into suggested answers
      description: |
        Extract suggested answers to a template's questions from a leader's
        free-spoken recap of a rounding conversation.

        **TAKES TEXT, NOT AUDIO.** The client transcribes on-device (iOS
        `SpeechDictationManager` / `SFSpeechRecognizer`), which removes the
        upload, the polling and any transcript storage — and works where the
        network does not, which matters for an app used in stairwells and med
        rooms. The web's `rounds/voicenote` route is unaffected: it exists to run
        Whisper on an uploaded clip, which on-device transcription makes
        unnecessary rather than wrong.

        **IT NEVER PICKS PEOPLE.** `recognition_pick` and `issue_capture`
        questions are excluded from the model's question set entirely, so it
        cannot invent a colleague or hand someone else's name a piece of work.
        Those stay manual; their ids come back in `excluded_question_ids` so a
        client can say so rather than leaving the leader to notice the gap.

        **IT WRITES NOTHING.** The response is suggestions the leader edits and
        then saves through `POST /rounds`. A suggestion the model could not make
        valid (a scale outside 1..5, an off-list choice, a blank) is dropped
        server-side rather than returned — a blank field the leader fills in is
        recoverable; a bad value would 422 at save time and read as the app
        losing their answers.

        Degrades honestly: when the model is unavailable or its output is
        unreadable the response is a 422 whose message tells the leader to type
        the answers instead.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - template_id
              - transcript
              properties:
                template_id:
                  type: integer
                  example: 1
                transcript:
                  type: string
                  description: The on-device transcript of the leader's recap.
                  example: Caught up with Marcus for fifteen minutes. Things are going
                    well since the swap flow shipped. On support he's about a four
                    out of five.
                answered_question_ids:
                  type: array
                  description: 'Optional. The template questions the client''s form
                    already holds a non-blank answer for. They are never offered to
                    the model and never come back in `suggestions`, so a second recap
                    fills the question that is still blank instead of re-matching
                    one already answered. When every fillable question is listed the
                    response is `suggestions: []` and no model call is made. Malformed
                    input (not an array, a non-integer member, an id not on the template)
                    is ignored, never refused; omitting it keeps the old behaviour.'
                  items:
                    type: integer
                  example:
                  - 101
      responses:
        '200':
          description: Suggestions extracted
          content:
            application/json:
              schema:
                type: object
                required:
                - suggestions
                - excluded_question_ids
                - skipped_question_ids
                properties:
                  suggestions:
                    type: array
                    description: Only questions the recap actually addressed. An omitted
                      question is expected, not a failure.
                    items:
                      type: object
                      properties:
                        question_id:
                          type: integer
                          example: 5
                        question_type:
                          type: string
                          enum:
                          - text
                          - scale
                          - boolean
                          - choice
                        value:
                          description: Typed to the question — string, 1..5 integer,
                            or boolean.
                          oneOf:
                          - type: string
                          - type: number
                          - type: boolean
                  excluded_question_ids:
                    type: array
                    description: Questions deliberately not filled — the recognition
                      pick and the issue owner stay manual.
                    items:
                      type: integer
                    example:
                    - 3
                    - 4
                  skipped_question_ids:
                    type: array
                    description: The `answered_question_ids` that were held back from
                      the model because the form already answers them — so a client
                      can tell "already answered" apart from "the recap didn't mention
                      it". Empty when none were sent.
                    items:
                      type: integer
                    example:
                    - 101
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, the caller is not a leader (`leader_access_required`)
            — this is a cost-bearing model call on the capture path, and its web twin
            is leader-gated — or the tenant has turned AI drafting off (`ai_drafting_disabled`,
            the `ai_drafting_enabled` app setting an app admin controls). The API
            is the door a tenant CANNOT close by hiding a button, so it refuses here
            too; a client should offer the manual form rather than retry.
        '422':
          description: 'Blank transcript (`transcript_required`), inactive template
            (`invalid_template`), nobody to round on (`nobody_to_round` — the caller''s
            `can_start_round` is false), AI not set up on this deployment (`ai_unavailable`),
            the model was unavailable / unreadable (`recap_failed`, `recap_unparsable`)
            — type the answers instead — or the tenant''s AI credit cap stopped the
            call (`ai_usage_blocked`). Render `ai_usage_blocked`''s `message` VERBATIM:
            unlike the others it carries the tenant''s own reason (an admin set `ai_monthly_credit_limit`,
            or unticked "Keep AI running past your credit balance", at /admin/billing),
            and it is the one 422 here that retrying cannot clear.'
  "/leader-rounds/scope-tree":
    get:
      tags:
      - Leader Rounds
      summary: One level of the reporting-line tree (scope picker)
      description: |
        Backs the org-chart scope picker on the ledger and the rollup: one level
        at a time, with a breadcrumb back up.

        **THE SHAPE IS DELIBERATELY APP-AGNOSTIC.** Per-node numbers are emitted
        as labelled `metrics[{label, value, tone}]` rather than
        `open_issues`/`red_issues` fields, so the client component that renders
        this can stay ignorant of Leader Rounds and be reused by the next app
        that grows a drill-down. Labels are server-authored, so they localize
        server-side like the rest of the payload.

        **Why not just use `/team`.** On the rollup, `/team?node=` already
        returns this level. But the LEDGER has the same picker and `/issues`
        returns no tree, so driving it from `/team` would construct a full
        coverage report plus a paginated per-leader array and discard nearly all
        of it — once per level, on a phone.

        A row aggregates its **whole subtree**, matching the rollup's unit rows —
        a picker row that counted only a node's own reports would disagree with
        the tile the user lands on after applying it. Colours come from the same
        `Stoplight` scopes the ledger uses, so the picker's "Red" means exactly
        what the ledger's red pill means.

        `Issues` is the subtree's **total** — resolved and won't-fix included —
        because applying the row lands on a ledger whose pills read `All (N)` /
        `Red (M)` over that same subtree, and the two numbers have to agree. It
        is the same pair the web's unit chips carry.

        **Children are ordered worst-first** (most red, then most issues, then
        name), with nodes that hold nothing anywhere in their subtree ordered
        LAST and flagged `quiet: true`. At a tenant root this level is every
        leader in the business — 66 of 69 rows read zero on the dev tenant — so
        alphabetical order buried the few that mattered. Quiet nodes are ordered
        last rather than dropped: on mobile this picker is the only way to reach
        a node, and on the rollup a team with no issues is often the one worth
        opening (nobody has rounded it, so it raises nothing). A client may
        collapse them behind a "show all" that states how many it is hiding.

        **An empty `children` array means the caller has nobody under them**, and
        a client should render no scope launcher at all rather than an empty
        sheet.
      security:
      - BearerAuth: []
      parameters:
      - name: node
        in: query
        required: false
        schema:
          type: integer
        description: Anchor at one leader in the caller's tree; blank opens at the
          caller's own default layer. An id outside the tree is refused (`node_not_visible`,
          403), never clamped — the picker only ever offers nodes the server just
          returned, so a bad node means a bug or an enumeration attempt.
      responses:
        '200':
          description: One level of the tree
          content:
            application/json:
              schema:
                type: object
                required:
                - breadcrumb
                - children
                properties:
                  node:
                    type: object
                    nullable: true
                    description: The anchored node, or null at the caller's root.
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                        nullable: true
                        description: The node's display name; null if the tree cannot
                          name it, so a client falls back to its breadcrumb entry.
                  breadcrumb:
                    type: array
                    description: Path from the top of the caller's span down to `node`.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  children:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LeaderRoundsScopeNode"
                  viewer_is_app_admin:
                    type: boolean
                    description: Whether the caller is a Leader Rounds app admin (or
                      business admin). Their root is the whole account; a leader's
                      is their own reporting line.
                    example: false
                  root_label:
                    type: string
                    enum:
                    - All teams
                    - My org
                    description: 'What to call the scope root — the label the web
                      uses: "All teams" for an app admin, "My org" for a leader.'
                    example: My org
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, or `node` outside the caller's tree.
  "/leader-rounds/calendar-pairings":
    post:
      tags:
      - Leader Rounds
      summary: Schedule recurring rounding with one person
      description: |
        "Put this on our calendars" — a recurring calendar event per
        (leader, subject) at the template cadence. Delegates to
        `LeaderRounds::CalendarPairingService`, shared with the web.

        **The event is convenience layered on top of the obligation, and can
        never make coverage lie in either direction** — cadence stays computed
        from completed rounds, not from calendar events.

        Degrades rather than failing: a missing Calendar licence, a subject
        outside the caller's reports, and an already-paired subject are all
        refusals with readable messages.

        **`invitee_ok: false` means the event exists on the LEADER's calendar but
        the subject's invite did not persist.** A client must not present that as
        "on both your calendars" — never report a degraded result as healthy.

        **A retry is safe.** When this person is already on the caller's
        calendars — most often because the first 201 was lost — the reply is
        **200** with the existing pairing and `replayed: true`: nothing was
        created, and the existing meeting's day and time are NOT changed (to
        move it, remove it with `DELETE /leader-rounds/calendar-pairings/{id}`
        and add it again). `invitee_ok` is null on that reply — this request
        sent no invite.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - subject_id
              - weekday
              - time_of_day
              properties:
                subject_id:
                  type: integer
                  example: 1752
                weekday:
                  type: integer
                  minimum: 0
                  maximum: 6
                  description: Day of the week the recurring check-in lands on, 0
                    = Sunday through 6 = Saturday. A day NAME ("Tuesday", "Tue") is
                    also accepted. Anything else is refused with 422 `invalid_weekday`
                    — it is not coerced.
                  example: 2
                time_of_day:
                  type: string
                  pattern: "^([01]?[0-9]|2[0-3]):[0-5][0-9]$"
                  description: Start time in the leader's own time zone (their profile
                    choice, else their location's, else the company's), 24-hour HH:MM.
                    Refused with 422 `invalid_time_of_day` if unreadable — it is not
                    defaulted to midnight.
                  example: '09:00'
      responses:
        '201':
          description: Pairing created
          content:
            application/json:
              schema:
                type: object
                required:
                - pairing
                properties:
                  pairing:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 1
                      subject_id:
                        type: integer
                        example: 1752
                      subject_name:
                        type: string
                        example: Marcus Bell
                  invitee_ok:
                    type: boolean
                    description: False = the subject's invite failed; do not claim
                      both calendars.
                    example: true
                  replayed:
                    type: boolean
                    description: Always false on a 201 — this request created the
                      pairing.
                    example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '200':
          description: 'This person is already on the caller''s calendars — a retry
            whose 201 was lost, or a second tap. The EXISTING pairing is returned
            with `replayed: true`; nothing was created and its day and time were not
            changed.'
          content:
            application/json:
              schema:
                type: object
                required:
                - pairing
                - replayed
                properties:
                  pairing:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 1
                      subject_id:
                        type: integer
                        example: 1752
                      subject_name:
                        type: string
                        example: Marcus Bell
                  invitee_ok:
                    type: boolean
                    nullable: true
                    description: Always null on a replay — this request sent no invite.
                    example:
                  replayed:
                    type: boolean
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '409':
          description: "`idempotency_conflict` — the `Idempotency-Key` header was
            already used for a different request. Nothing was changed; send a new
            key. `idempotency_in_progress` — a request with the same `Idempotency-Key`
            header is still running; retry after `Retry-After` seconds."
        '422':
          description: Calendar app not enabled (`calendar_disabled`), the Calendar
            app on but not open to the caller — not published to them, or outside
            its groups (`calendar_not_open`), subject not someone the caller owes
            rounds on (`invalid_subject`), a `weekday` that is missing or unreadable
            (`invalid_weekday`), a `time_of_day` that is missing or not 24-hour HH:MM
            (`invalid_time_of_day`), or the calendar call failed — either `calendar_error`,
            or a code passed through from the Calendar app when it refused the event
            — chiefly `policy_violation` (the tenant's calendar settings do not allow
            a recurring event) and `invalid` (an event it rejected) — with a readable
            `error.message`; treat any other code here as "the calendar call failed".
            The two time codes are REFUSALS, not coercions — the service stopped defaulting
            an unparseable weekday to Sunday and a missing time to midnight, which
            had booked real recurring midnight-Sunday meetings on two calendars behind
            a 201. An id that is not an active member of the tenant and an id that
            is a member but not one the caller owes rounds on answer IDENTICALLY —
            `calendar_disabled` while the Calendar app is off, `invalid_subject` otherwise,
            same body — the same non-enumeration rule this file states for `?leader_id=`.
  "/leader-rounds/calendar-pairings/{id}":
    delete:
      tags:
      - Leader Rounds
      summary: Remove a recurring rounding meeting
      description: |
        Removes the pairing and its calendar event. A pairing is the leader's
        own; app admins may clean up any — the same tier the web applies. When
        the event cannot be removed the pairing is kept and the answer is 422.
        The token asks `destructive:leader_rounds` (a final act, #6721); without
        it the answer is 403 `insufficient_permissions`.

        Send an `Idempotency-Key`: a retry whose 200 was lost then gets that
        200 back instead of a 404 for a removal that worked.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: 'Retry-safety. A repeat with the same key from the same caller
          replays the first SUCCESS — same status and data (a replay carries no `unread_notification_count`),
          with `X-Idempotency-Cached: true` — and runs nothing again. The same key
          with a different request body is 409 `idempotency_conflict`; a repeat sent
          while the first is still running is a retryable 409 `idempotency_in_progress`
          (wait for `Retry-After`). Refusals are not stored, so a corrected retry
          runs. Kept for 24 hours.'
        schema:
          type: string
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible.
        '404':
          description: Not found, or not a pairing the caller may remove.
        '422':
          description: "`calendar_error` — the meeting could not be removed from the
            calendar. The pairing is kept (it is the only link to the meeting), so
            a retry tries again; not stored under the `Idempotency-Key`."
        '409':
          description: "`idempotency_conflict` — the `Idempotency-Key` header was
            already used for a different request. Nothing was changed; send a new
            key. `idempotency_in_progress` — a request with the same `Idempotency-Key`
            header is still running; retry after `Retry-After` seconds."
  "/leader-rounds/team-listens":
    get:
      tags:
      - Leader Rounds
      summary: My team conversations before rounds
      description: |
        The caller's own "Hear from your team before your rounds" conversations
        — anonymous AI conversations with their team — newest first. Leaders
        only (403 `leader_access_required`), and only while the tenant has team
        conversations on (403 `team_listens_disabled`).

        **Nothing of what was said is shown while one is open** — only
        `took_part`, a count: watching it change after each answer would point
        to who said what. An open one also carries `floor` and `enough_people`
        (whether at least `floor` have taken part so far): closed below the
        floor, nothing they said is shown. A closed one carries its readout, already floored:
        a topic counts people only when at least `floor` did and at least
        `floor` (or nobody) did not; quotes only from people who allowed
        quoting. Below the floor there is nothing but the count (`summary`).

        `state` is one of `open`, `pending` (closed, the answers are being put
        together), `failed` (`error` says why), `too_few` or `ready`.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: The caller's team conversations
          headers:
            X-Total-Count:
              description: Rows in the whole list, across every page.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages at the applied `per_page`.
              schema:
                type: integer
            X-Current-Page:
              description: The page this response holds (1-based).
              schema:
                type: integer
            X-Per-Page:
              description: The page size actually applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - team_listens
                - meta
                properties:
                  team_listens:
                    type: array
                    items:
                      type: object
                      description: 'One "Hear from your team before your rounds" conversation,
                        as its leader may see it. Open: a count only, with `floor`
                        and whether `enough_people` have taken part so far. Closed
                        and ready: the readout, already floored — counts of people
                        only for groups of at least `floor`, and never who said what.'
                      required:
                      - id
                      - status
                      - state
                      - opens_on
                      - closes_on
                      - invited
                      properties:
                        id:
                          type: integer
                        status:
                          type: string
                          enum:
                          - open
                          - closed
                        state:
                          type: string
                          enum:
                          - open
                          - pending
                          - failed
                          - too_few
                          - ready
                        opens_on:
                          type: string
                          format: date
                          nullable: true
                        closes_on:
                          type: string
                          format: date
                          nullable: true
                        invited:
                          type: integer
                          description: How many people were invited.
                        sample_data:
                          type: boolean
                          description: A demo conversation from Load Sample Data —
                            its readout is not a team's real answers.
                        took_part:
                          type: integer
                          description: How many have taken part — the only count of
                            people shown while open.
                        floor:
                          type: integer
                          description: The smallest group anything said is shown for.
                        enough_people:
                          type: boolean
                          description: 'Whether at least `floor` took part — while
                            open, so far: closed below the floor, nothing they said
                            is shown.'
                        summary:
                          type: string
                          description: 'Below the floor: the one sentence shown, with
                            nothing else.'
                        error:
                          type: string
                          nullable: true
                          description: 'When `state` is failed: why the answers couldn''t
                            be put together. Absent in every other state, and may
                            be null on a failed one when no reason was recorded.'
                        topics:
                          type: array
                          items:
                            type: object
                            properties:
                              key:
                                type: string
                                enum:
                                - going_well
                                - in_the_way
                                - ask_on_rounds
                              topic:
                                type: string
                              question:
                                type: string
                                description: The question asked
                                as written.:
                              people:
                                type: integer
                              withheld_below_floor:
                                type: boolean
                                description: Fewer than the floor answered — nothing
                                  of it is shown.
                              what_several_people_said:
                                type: array
                                items:
                                  type: string
                                description: Points each resting on at least the floor
                                  of different people.
                              codes:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    label:
                                      type: string
                                    people:
                                      type: integer
                                    quotes:
                                      type: array
                                      items:
                                        type: string
                                      description: Only from people who allowed quoting,
                                        with details removed.
                        new_themes:
                          type: array
                          items:
                            type: object
                            properties:
                              label:
                                type: string
                              summary:
                                type: string
                              people:
                                type: integer
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/LeaderRoundsPagination"
                    - type: object
                      description: The base API's standard pagination names, sent
                        beside this API's original `page` / `per_page` / `total` /
                        `total_pages` so a client's shared pager reads this list the
                        same way as every other /api/v1 list. `current_page` equals
                        `page` and `total_count` equals `total`.
                      required:
                      - current_page
                      - total_count
                      - has_next_page
                      - has_prev_page
                      properties:
                        current_page:
                          type: integer
                          example: 1
                        total_count:
                          type: integer
                          example: 42
                        has_next_page:
                          type: boolean
                          example: true
                        has_prev_page:
                          type: boolean
                          example: false
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, not a leader, or team conversations are
            off.
  "/leader-rounds/team-listens/{id}":
    get:
      tags:
      - Leader Rounds
      summary: One of my team conversations
      description: |
        One of the caller's own team conversations, in the same shape as the
        list. Another leader's is 404, the same as one that does not exist.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The team conversation
          content:
            application/json:
              schema:
                type: object
                required:
                - team_listen
                properties:
                  team_listen:
                    type: object
                    description: 'One "Hear from your team before your rounds" conversation,
                      as its leader may see it. Open: a count only, with `floor` and
                      whether `enough_people` have taken part so far. Closed and ready:
                      the readout, already floored — counts of people only for groups
                      of at least `floor`, and never who said what.'
                    required:
                    - id
                    - status
                    - state
                    - opens_on
                    - closes_on
                    - invited
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        enum:
                        - open
                        - closed
                      state:
                        type: string
                        enum:
                        - open
                        - pending
                        - failed
                        - too_few
                        - ready
                      opens_on:
                        type: string
                        format: date
                        nullable: true
                      closes_on:
                        type: string
                        format: date
                        nullable: true
                      invited:
                        type: integer
                        description: How many people were invited.
                      sample_data:
                        type: boolean
                        description: A demo conversation from Load Sample Data — its
                          readout is not a team's real answers.
                      took_part:
                        type: integer
                        description: How many have taken part — the only count of
                          people shown while open.
                      floor:
                        type: integer
                        description: The smallest group anything said is shown for.
                      enough_people:
                        type: boolean
                        description: 'Whether at least `floor` took part — while open,
                          so far: closed below the floor, nothing they said is shown.'
                      summary:
                        type: string
                        description: 'Below the floor: the one sentence shown, with
                          nothing else.'
                      error:
                        type: string
                        nullable: true
                        description: 'When `state` is failed: why the answers couldn''t
                          be put together. Absent in every other state, and may be
                          null on a failed one when no reason was recorded.'
                      topics:
                        type: array
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                              enum:
                              - going_well
                              - in_the_way
                              - ask_on_rounds
                            topic:
                              type: string
                            question:
                              type: string
                              description: The question asked
                              as written.:
                            people:
                              type: integer
                            withheld_below_floor:
                              type: boolean
                              description: Fewer than the floor answered — nothing
                                of it is shown.
                            what_several_people_said:
                              type: array
                              items:
                                type: string
                              description: Points each resting on at least the floor
                                of different people.
                            codes:
                              type: array
                              items:
                                type: object
                                properties:
                                  label:
                                    type: string
                                  people:
                                    type: integer
                                  quotes:
                                    type: array
                                    items:
                                      type: string
                                    description: Only from people who allowed quoting,
                                      with details removed.
                      new_themes:
                        type: array
                        items:
                          type: object
                          properties:
                            label:
                              type: string
                            summary:
                              type: string
                            people:
                              type: integer
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: App not accessible, not a leader, or team conversations are
            off.
        '404':
          description: Not found, or not the caller's.
  "/communications/action-outcomes/{target_kind}/{target_id}":
    get:
      tags:
      - Comms Hub
      summary: Read source-linked action outcomes
      description: |
        Requires read:communication_outcomes, Communications app access, publication
        authority for this business-scoped communication, and the owning source
        app's result permission. Supports policy acknowledgement, course completion,
        identified forms and identified surveys; anonymous sources are excluded.
        Frozen audience totals and minimal source observations describe action
        completion following communication, not causation. No answers are returned.
        Responses are private and not cacheable. Page size is 50.
      security:
      - BearerAuth: []
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: Frozen audience and current source observations
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - data
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  data:
                    type: object
                    properties:
                      action:
                        type: object
                        description: Source kind, ID, title and requested version;
                          no answers.
                      sent_at:
                        type: string
                        format: date-time
                      deadline_at:
                        type: string
                        format: date-time
                      audience_count:
                        type: integer
                      eligible_count:
                        type: integer
                      departed_count:
                        type: integer
                      completed_before_ever_count:
                        type: integer
                      counts:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts by pending, overdue, completed_before,
                          completed_after, completed_late, not_eligible, unknown or
                          revoked.
                      last_reconciled_at:
                        type: string
                        format: date-time
                        nullable: true
                      warning:
                        type: string
                        nullable: true
                      communication_fingerprint:
                        type: string
                      page:
                        type: integer
                      total_pages:
                        type: integer
                      interpretation:
                        type: string
                      rows:
                        type: array
                        items:
                          type: object
                          properties:
                            user_id:
                              type: integer
                            name:
                              type: string
                            active:
                              type: boolean
                            eligible:
                              type: boolean
                            state:
                              type: string
                            completed_at:
                              type: string
                              format: date-time
                              nullable: true
                            observed_at:
                              type: string
                              format: date-time
                            source_record_type:
                              type: string
                              nullable: true
                            source_record_id:
                              type: integer
                              nullable: true
                            reason:
                              type: string
                              nullable: true
                            required_occurrence_id:
                              type: integer
                              nullable: true
                            history:
                              type: array
                              maxItems: 5
                              items:
                                type: object
                                description: Prior state, observation/completion timestamps
                                  and source record ID.
        '401':
          description: Authentication required
        '403':
          description: Token scope
          app access:
          publication or source-result permission refused:
        '404':
          description: Communication or configured action unavailable in this business
        '422':
          description: Communication not sent
          or original audience/observations incomplete:
  "/communications/experiments/{issue_id}":
    get:
      tags:
      - Comms Hub
      summary: Read a versioned subject-experiment report
      description: |
        Requires read:communication_outcomes and the same Communications publication
        and source-result authority as the web report. Returns aggregate results
        only; frozen input rows, assignment identities and source answers are omitted.
        Small cells are suppressed. Missing outcomes, inadequate sample size,
        feasibility plans and reported interference remain inconclusive. Assignment
        compares two subjects for the same newsletter; delivery is not employee reading.
        Saved analysis versions are immutable. Independent reproduction recorded
        means the named reviewer's calculation and attestation were recorded; it
        does not certify external analyst competence or a live customer pilot.
      security:
      - BearerAuth: []
      parameters:
      - name: issue_id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
      - name: version
        in: query
        schema:
          type: integer
          minimum: 1
        description: Saved analysis version; omitted selects the latest.
      responses:
        '200':
          description: Study definition and aggregate analysis, when available
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - data
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  data:
                    type: object
                    properties:
                      issue_id:
                        type: integer
                      status:
                        type: string
                        enum:
                        - draft
                        - submitted
                        - approved
                        - rejected
                        - withdrawn
                        - running
                      question:
                        type: string
                      assignment_unit:
                        type: string
                        enum:
                        - person
                        - site
                        - group
                      purpose:
                        type: string
                        enum:
                        - effect
                        - feasibility
                      observation_days:
                        type: integer
                      deadline_at:
                        type: string
                        format: date-time
                        nullable: true
                      interpretation:
                        type: string
                      analysis:
                        type: object
                        nullable: true
                        properties:
                          version:
                            type: integer
                          as_of:
                            type: string
                            format: date-time
                          method:
                            type: string
                          input_digest:
                            type: string
                          independent_reproduction_recorded:
                            type: boolean
                          results:
                            type: object
                            description: Suppression, fixed group denominators, conclusion
                              and reasons, estimate/95% interval when supportable,
                              missing-outcome bounds, exclusions and coarse delivery
                              facts.
        '401':
          description: Authentication required
        '403':
          description: Token scope
          app access:
          publication or source-result permission refused:
        '404':
          description: Study or requested analysis version unavailable in this business
  "/communications/delegated_reviews":
    get:
      tags:
      - Comms Hub
      summary: List messages waiting for your review
      description: |
        Posts and broadcasts a delegate wrote to go out under the caller's name, waiting
        for the caller to approve or decline them, newest first — what the web Reviews
        page shows the same person. Only the person a message is sent as sees it.
        Requires read:news_feed (posts) or read:broadcasts (broadcasts); the list holds
        only the kinds the token may read. Communications must be on for the tenant.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller's review queue
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DelegatedReview"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
        '401':
          description: Authentication required or token invalid
        '403':
          description: insufficient_permissions — the token lacks the scope; app_disabled
            or app_unavailable — Communications is off or not open to the caller.
  "/communications/delegated_reviews/{kind}/{id}/approve":
    post:
      tags:
      - Comms Hub
      summary: Approve a delegate's message (send it as yourself)
      description: |
        The caller's own send, with their permissions: a post goes out or is scheduled
        (or waits for the company's approval workflow); a broadcast runs the composer's
        gates — the send time the delegate picked, the approval workflow, content
        moderation, then send or schedule. The delegate is told. Requires write:news_feed
        for a post, write:broadcasts for a broadcast. Pass `seen` (the list item's `seen`)
        to refuse a version the delegate changed since the caller read it.
      security:
      - BearerAuth: []
      parameters:
      - name: kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - post
          - broadcast
      - name: id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                seen:
                  type: integer
                  description: The list item's `seen` value (its last-changed time,
                    epoch seconds).
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      kind:
                        type: string
                        enum:
                        - post
                        - broadcast
                      status:
                        type: string
                        enum:
                        - published
                        - scheduled
                        - pending_approval
                        - held_for_moderation
                        description: published — sent now; scheduled — goes out at
                          scheduled_at; pending_approval — with the company's reviewers,
                          who send it; held_for_moderation — a moderator sends it.
                      scheduled_at:
                        type: string
                        format: date-time
                        nullable: true
                      message:
                        type: string
        '401':
          description: Authentication required or token invalid
        '403':
          description: insufficient_permissions — the token lacks the scope; app_disabled
            or app_unavailable — Communications is off or not open to the caller.
        '404':
          description: not_found — the message is not waiting for the caller (missing,
            or sent as someone else).
        '409':
          description: already_decided — someone already approved or declined it;
            changed_since_seen — the delegate changed it since `seen` (details.review
            holds the current version).
        '422':
          description: approval_refused — it could not be sent (details.errors say
            why); it is still waiting for review.
  "/communications/delegated_reviews/{kind}/{id}/decline":
    post:
      tags:
      - Comms Hub
      summary: Decline a delegate's message
      description: |
        Not sent: it stays in the caller's drafts, and the delegate is told, with the
        caller's note when one is given (an app that drafted it is told nothing).
        Requires write:news_feed for a post, write:broadcasts for a broadcast.
      security:
      - BearerAuth: []
      parameters:
      - name: kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - post
          - broadcast
      - name: id
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                note:
                  type: string
                  description: Why it was not sent; the delegate reads it (first 300
                    characters).
      responses:
        '200':
          description: Declined
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      kind:
                        type: string
                        enum:
                        - post
                        - broadcast
                      status:
                        type: string
                        enum:
                        - returned
                      delegate_told:
                        type: boolean
                      message:
                        type: string
        '401':
          description: Authentication required or token invalid
        '403':
          description: insufficient_permissions — the token lacks the scope; app_disabled
            or app_unavailable — Communications is off or not open to the caller.
        '404':
          description: not_found — the message is not waiting for the caller.
        '409':
          description: already_decided — someone already approved or declined it.
  "/communications/delivery/{target_kind}/{target_id}":
    get:
      tags:
      - Comms Hub
      summary: Preview channel delivery and read history
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      preview:
                        type: object
                      destinations:
                        type: array
                        items:
                          type: object
                      deliveries:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            destination_id:
                              type: integer
                            target_id:
                              type: string
                            target_name:
                              type: string
                            status:
                              type: string
                            external_id:
                              type: string
                              nullable: true
                            sent_at:
                              type: string
                              format: date-time
                              nullable: true
                            error_message:
                              type: string
                              nullable: true
                            delivery_data:
                              type: object
                      whatsapp:
                        type: object
                        nullable: true
                      whatsapp_deliveries:
                        type: array
                        items:
                          type: object
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
    post:
      tags:
      - Comms Hub
      summary: Queue a Teams or Slack delivery
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      destination_id:
                        type: integer
                      target_id:
                        type: string
                      target_name:
                        type: string
                      status:
                        type: string
                      external_id:
                        type: string
                        nullable: true
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                      error_message:
                        type: string
                        nullable: true
                      delivery_data:
                        type: object
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                destination:
                  type: string
                  description: Exact destination key from the preview, connection_id:channel_id.
              required:
              - destination
  "/communications/delivery/{target_kind}/{target_id}/{id}/retry":
    post:
      tags:
      - Comms Hub
      summary: Retry a failed channel delivery
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      destination_id:
                        type: integer
                      target_id:
                        type: string
                      target_name:
                        type: string
                      status:
                        type: string
                      external_id:
                        type: string
                        nullable: true
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                      error_message:
                        type: string
                        nullable: true
                      delivery_data:
                        type: object
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/communications/delivery/{target_kind}/{target_id}/{id}/reconcile":
    post:
      tags:
      - Comms Hub
      summary: Reconcile an uncertain channel delivery
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      destination_id:
                        type: integer
                      target_id:
                        type: string
                      target_name:
                        type: string
                      status:
                        type: string
                      external_id:
                        type: string
                        nullable: true
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                      error_message:
                        type: string
                        nullable: true
                      delivery_data:
                        type: object
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/communications/delivery/{target_kind}/{target_id}/whatsapp/send":
    post:
      tags:
      - Comms Hub
      summary: Queue eligible WhatsApp recipients
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend. WhatsApp supports
        broadcast targets only. Requires an administrator-reviewed template/language
        and central WhatsApp sender, explicit recipient consent and a current verified
        unique phone identity. Preview reports eligible and excluded recipients. Credentials,
        raw phone fingerprints and callback nonces are omitted.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                  warnings:
                    type: array
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
  "/communications/delivery/{target_kind}/{target_id}/whatsapp/{id}/retry":
    post:
      tags:
      - Comms Hub
      summary: Retry an eligible WhatsApp delivery
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend. WhatsApp supports
        broadcast targets only. Requires an administrator-reviewed template/language
        and central WhatsApp sender, explicit recipient consent and a current verified
        unique phone identity. Preview reports eligible and excluded recipients. Credentials,
        raw phone fingerprints and callback nonces are omitted.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                  warnings:
                    type: array
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/communications/delivery/{target_kind}/{target_id}/whatsapp/{id}/reconcile":
    post:
      tags:
      - Comms Hub
      summary: Reconcile an uncertain WhatsApp delivery
      description: Requires the Communications app, publishing authority for the business-scoped
        target and read:communication_deliveries for reads or write:communication_deliveries
        for mutations. Only configured central integrations and explicitly allowed
        channels can receive deliveries. Provider delivery is distinct from employee
        reading or completion. Timeouts with uncertain provider results require positive
        reconciliation before a retry; clients must not blindly resend. WhatsApp supports
        broadcast targets only. Requires an administrator-reviewed template/language
        and central WhatsApp sender, explicit recipient consent and a current verified
        unique phone identity. Preview reports eligible and excluded recipients. Credentials,
        raw phone fingerprints and callback nonces are omitted.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                  warnings:
                    type: array
                    items:
                      type: string
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: target_kind
        in: path
        required: true
        schema:
          type: string
          enum:
          - issue
          - feed
          - broadcast
      - name: target_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/communications/whatsapp/preference":
    get:
      tags:
      - Comms Hub
      summary: Read my WhatsApp broadcast preference
      description: Requires read:own_profile and current Communications app access.
        The caller can read only their own preference.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      available:
                        type: boolean
                        description: The company sends broadcasts by WhatsApp. Turning
                          the preference on is refused while this is false.
                      phone_verified:
                        type: boolean
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
    patch:
      tags:
      - Comms Hub
      summary: Change my WhatsApp broadcast preference
      description: Requires update:own_profile and current Communications app access.
        Enabling requires a verified phone identity and WhatsApp delivery turned on
        for the company. Provider STOP and global opt-out remain separate safety gates.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      available:
                        type: boolean
                      phone_verified:
                        type: boolean
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: |
            Turning it on was refused: the company does not send broadcasts by
            WhatsApp (`available` is false on GET), or the caller's mobile
            number is not verified (`phone_verified`). `message` says which, in
            words to show. Turning it off is never refused.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: false
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: invalid_preference
                      message:
                        type: string
                        example: Your company doesn't send broadcasts by WhatsApp.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled:
                  type: boolean
              required:
              - enabled
  "/communications/sms/{broadcast_id}":
    get:
      tags:
      - Comms Hub
      summary: Read private SMS correspondence for my announcement
      description: 'Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        direction:
                          type: string
                          enum:
                          - inbound
                          - outbound
                        status:
                          type: string
                        reason:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        actor_id:
                          type: integer
                          nullable: true
                        broadcast_id:
                          type: integer
                          nullable: true
                        conversation_url:
                          type: string
                          nullable: true
                        body:
                          type: string
                          nullable: true
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                      pages:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: broadcast_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
  "/communications/sms/{broadcast_id}/{id}/reply":
    post:
      tags:
      - Comms Hub
      summary: Reply privately by SMS
      description: 'Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      direction:
                        type: string
                        enum:
                        - inbound
                        - outbound
                      status:
                        type: string
                      reason:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      actor_id:
                        type: integer
                        nullable: true
                      broadcast_id:
                        type: integer
                        nullable: true
                      conversation_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                        nullable: true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: broadcast_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: Nonblank reply text; service enforces the configured
                    SMS bound.
                operation_key:
                  type: string
                  format: uuid
              required:
              - body
              - operation_key
  "/communications/sms/{broadcast_id}/{id}/retry":
    post:
      tags:
      - Comms Hub
      summary: Retry a failed SMS reply
      description: 'Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      direction:
                        type: string
                        enum:
                        - inbound
                        - outbound
                      status:
                        type: string
                      reason:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      actor_id:
                        type: integer
                        nullable: true
                      broadcast_id:
                        type: integer
                        nullable: true
                      conversation_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                        nullable: true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: broadcast_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/communications/sms/{broadcast_id}/{id}/reconcile":
    post:
      tags:
      - Comms Hub
      summary: Reconcile an uncertain SMS reply
      description: 'Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      direction:
                        type: string
                        enum:
                        - inbound
                        - outbound
                      status:
                        type: string
                      reason:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      actor_id:
                        type: integer
                        nullable: true
                      broadcast_id:
                        type: integer
                        nullable: true
                      conversation_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                        nullable: true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: broadcast_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
  "/communications/sms/held":
    get:
      tags:
      - Comms Hub
      summary: Read unmatched inbound SMS replies
      description: 'Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted. Business administrator only. Held content is
        retained for bounded manual matching; it is not exposed to unrelated authors.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        direction:
                          type: string
                          enum:
                          - inbound
                          - outbound
                        status:
                          type: string
                        reason:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        actor_id:
                          type: integer
                          nullable: true
                        broadcast_id:
                          type: integer
                          nullable: true
                        conversation_url:
                          type: string
                          nullable: true
                        body:
                          type: string
                          nullable: true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
  "/communications/sms/held/{id}/resolve":
    post:
      tags:
      - Comms Hub
      summary: Match a held SMS reply
      description: 'Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted. Business administrator only. Matching rechecks
        the uniquely verified employee and frozen original recipient identity; it
        cannot manufacture eligibility or complete an owning-source requirement.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      direction:
                        type: string
                        enum:
                        - inbound
                        - outbound
                      status:
                        type: string
                      reason:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      actor_id:
                        type: integer
                        nullable: true
                      broadcast_id:
                        type: integer
                        nullable: true
                      conversation_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                        nullable: true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                broadcast_id:
                  type: integer
                  minimum: 1
              required:
              - broadcast_id
  "/communications/sms/held/{id}/dismiss":
    post:
      tags:
      - Comms Hub
      summary: Dismiss and clear a held SMS reply
      description: |
        Requires read:communication_deliveries for reads or write:communication_deliveries
        for mutations and current Communications access. Announcement replies are
        private employee-to-author correspondence: publishing access alone does not
        grant access to another author’s inbox. Identity, original audience, opt-out,
        settings, budget and provider state are rechecked before sending. Uncertain
        sends must be reconciled before retry. Raw phone identities, credentials and
        callback nonces are omitted. Business administrator only. Matching rechecks
        the uniquely verified employee and frozen original recipient identity; it
        cannot manufacture eligibility or complete an owning-source requirement.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:communication_deliveries` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      id:
                        type: integer
                      direction:
                        type: string
                        enum:
                        - inbound
                        - outbound
                      status:
                        type: string
                      reason:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      actor_id:
                        type: integer
                        nullable: true
                      broadcast_id:
                        type: integer
                        nullable: true
                      conversation_url:
                        type: string
                        nullable: true
                      body:
                        type: string
                        nullable: true
        '401':
          description: Authentication required
        '403':
          description: Token scope, application access or action permission refused
        '404':
          description: Record unavailable in this business or to this participant
        '422':
          description: Invalid request; response includes a user-facing error
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
              required:
              - reason
  "/comms_hub/dashboard":
    get:
      tags:
      - Comms Hub
      summary: Communications home dashboard
      description: |
        The native-client mirror of the web Communications home
        (`Apps::NewsFeedController#show`) — the "Mobile Comms App" home screen.

        Every number and list is produced by the **same** query object that
        backs the web page (`NewsFeed::DashboardStats`), so the two surfaces
        cannot drift. Each metric is one memoized, business-scoped, N+1-free
        query hanging off a single shared visibility base
        (`accessible_by ∩ published ∩ active`).

        The payload carries the seven Home surfaces:

        * `counts.unread` — audience-visible published posts the caller has not
          opened ("Unread for You").
        * `counts.pending_acknowledgements` — every message still awaiting the
          caller's acknowledgement: must-read posts and broadcasts ("Needs
          Acknowledgement"; since 2026-10-10 it counts broadcasts too — the
          caller's owed broadcasts that published a feed post,
          `NewsFeed::Feed.owed_message_by`). `must_read` below stays
          must-read posts only.
        * `counts.verified_answers` — question posts that carry a verified
          answer ("Verified answers").
        * `counts.unread_messages` — the caller's unread Messages, the same
          total as `GET /messaging/threads/unread_count`; `null` when the
          company has Messages off or the token lacks `read:messages`.
        * `have_your_say` — the freshest open poll the caller has not voted on,
          or `null`.
        * `latest` — the freshest published posts as glance rows ("Latest from
          the company"); at most 4.
        * `must_read` — the top unacknowledged must-read with company
          acknowledgement progress, or `null` when the caller is all caught up.
        * `trending_topics` — the most-tagged topics across the last 30 days; at
          most 5.

        **Required Scopes:** `read:broadcasts` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope") — the Communications app's REST
        vocabulary, and the scope Ask AI asks for the same audiences. A token
        without it gets `403 insufficient_permissions` ("Required scope:
        read:broadcasts") before anything else is checked. The sign-in tokens
        hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - dashboard
                properties:
                  dashboard:
                    type: object
                    required:
                    - counts
                    - latest
                    - trending_topics
                    properties:
                      counts:
                        type: object
                        required:
                        - unread
                        - pending_acknowledgements
                        - verified_answers
                        properties:
                          unread:
                            type: integer
                            example: 3
                          pending_acknowledgements:
                            type: integer
                            example: 1
                          verified_answers:
                            type: integer
                            example: 7
                          unread_messages:
                            type: integer
                            nullable: true
                            example: 1
                            description: Unread Messages; null when Messages is off
                              for the company or the token lacks read:messages.
                      have_your_say:
                        type: object
                        nullable: true
                        description: The surfaced "Have your say" open poll, or null
                          when none applies.
                        properties:
                          id:
                            type: integer
                          headline:
                            type: string
                            description: Poll headline, falling back to a stripped/truncated
                              body (≤ 90 chars).
                          closes_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the poll closes; null for a poll with
                              no end date ("Poll open now").
                          published_at:
                            type: string
                            format: date-time
                            nullable: true
                      latest:
                        type: array
                        description: '"Latest from the company" glance rows, newest
                          first (≤ 4).'
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            headline:
                              type: string
                              description: Post headline, falling back to a stripped/truncated
                                body (≤ 90 chars).
                            kind:
                              type: string
                              enum:
                              - post
                              - announcement
                              - must_read
                              - break_through
                              description: The post's kind as Compose names it — the
                                feed payload's `kind`; `break_through` is a Broadcast's
                                post.
                            kind_label:
                              type: string
                              nullable: true
                              description: The reader's word for `kind` (the company's
                                own label when it chose one) — the feed payload's
                                `kind_label`.
                            author:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                                  nullable: true
                                avatar_url:
                                  type: string
                                  nullable: true
                                  description: Absolute avatar URL (null when it cannot
                                    be resolved).
                            published_at:
                              type: string
                              format: date-time
                              nullable: true
                            topic:
                              type: string
                              nullable: true
                              description: Name of the post's first topic chip, or
                                null.
                            comment_count:
                              type: integer
                              description: Active (non-deleted, non-moderated) comment
                                count.
                            reaction_count:
                              type: integer
                      must_read:
                        type: object
                        nullable: true
                        description: Must-Read status card, or null when the caller
                          is all caught up.
                        properties:
                          id:
                            type: integer
                          headline:
                            type: string
                            description: Post headline, falling back to a stripped/truncated
                              body (≤ 60 chars).
                          expires_at:
                            type: string
                            format: date-time
                            nullable: true
                          expires_in:
                            type: string
                            nullable: true
                            description: Human-readable expiry (e.g. "Expires in 2
                              days"), or null when it never expires. When the post
                              stops asking, not when it is due — draw `due_text` for
                              the deadline.
                          due_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: The caller's own acknowledge-by moment (the
                              post's due date, moved on for someone asked late), or
                              null when it asks for none.
                          due_text:
                            type: string
                            nullable: true
                            description: The deadline in the words the web strip says
                              ("Due Fri, Oct 30 at 5:00 PM", "Was due …"), or null.
                          overdue:
                            type: boolean
                            description: Whether the caller's acknowledgement is past
                              its due date while the post still asks.
                          acknowledged:
                            type: boolean
                            description: Whether the calling user has personally acknowledged
                              this post.
                          acknowledgement_count:
                            type: integer
                            description: Company acknowledgement count from the analytics
                              cache (0 until computed).
                          acknowledgement_pct:
                            type: integer
                            nullable: true
                            description: Company acknowledgement percentage (acks
                              ÷ audience), or null until the snapshot exists.
                          names_hidden:
                            type: object
                            description: |
                              Present only while the post's works-council group (General
                              Settings › Countries) is under the company's smallest reportable
                              group: `acknowledgement_count` and `acknowledgement_pct` then
                              count only the named people, and the group is not counted — `count`
                              and `group_count` are null and `fewer_than` is that group, "a few"
                              (the shape of `BroadcastNamesHidden`).
                            properties:
                              count:
                                type: integer
                                nullable: true
                              group_count:
                                type: integer
                                nullable: true
                                description: 'Null since #4546: a hidden group under
                                  the company''s smallest group is "a few" (`fewer_than`).
                                  Kept for older clients.'
                              fewer_than:
                                type: integer
                                description: 'Present only when the people it leaves
                                  out (or the hidden group) are fewer than the company''s
                                  smallest group: that number — they are "a few",
                                  and `count` / `group_count` are null (#4546).'
                              reason:
                                type: string
                                example: Names are hidden for people in Germany (works
                                  council).
                          policy:
                            type: object
                            nullable: true
                            description: |
                              The HR policy this must-read asks the reader to accept, plus THIS
                              caller's acceptance of it, or null when the post names no policy.
                              Byte-for-byte the same block a feed payload carries (`policy` on
                              `GET /feeds/{id}`) — one serializer feeds both — so the card can
                              draw the whole "Policy you're asked to accept" row (title, an
                              accepted / not-yet-accepted chip, and where to read and accept it)
                              without a follow-up fetch. Acceptance is tracked separately from
                              acknowledging the post; render the two states independently.
                            properties:
                              id:
                                type: integer
                              title:
                                type: string
                              status:
                                type: string
                                enum:
                                - draft
                                - published
                                - archived
                                - retired
                                description: |
                                  The policy's own lifecycle state, verbatim from the record —
                                  HrPolicy validates `draft | published | archived | retired`, and
                                  this block is emitted for whatever policy the must-read names, so
                                  all four are reachable. Only `published` is linkable (`url` and
                                  `acknowledge_url` go null otherwise); keep rendering the title on
                                  an existing must-read whatever the state says.
                              requires_acknowledgment:
                                type: boolean
                                description: Whether the policy asks to be accepted
                                  at all. When false, name the policy without promising
                                  an accept action.
                              accepted:
                                type: boolean
                                description: Whether this caller has accepted the
                                  CURRENT version. False when HR has flagged them
                                  for re-acknowledgment, even though an older acceptance
                                  row exists.
                              accepted_at:
                                type: string
                                format: date-time
                                nullable: true
                                description: When they accepted. Null unless `accepted`
                                  is true.
                              url:
                                type: string
                                nullable: true
                                description: Absolute URL of the mobile Policy Hub
                                  screen (reads AND accepts) — the "Read and accept"
                                  target. Null when the policy is no longer published
                                  or Policy Hub isn't reachable by THIS caller, so
                                  a client never renders a link that only bounces.
                              acknowledge_url:
                                type: string
                                nullable: true
                                description: Absolute URL to POST (empty body) to
                                  accept the policy in place, so the card can offer
                                  the accept action itself. Null whenever that POST
                                  would be refused — see the same field on the feed
                                  payload for the full rule.
                              acknowledge_url_reason:
                                type: string
                                nullable: true
                                enum:
                                - not_published
                                - app_unavailable
                                - not_applicable
                                - esignature_required
                                - not_targeted
                                description: Why there is no in-place accept action
                                  — non-null exactly when `acknowledge_url` is null,
                                  null otherwise. Same codes and rule as the feed
                                  payload's field.
                      trending_topics:
                        type: array
                        description: Most-tagged topics across the last 30 days (≤
                          5).
                        items:
                          type: object
                          properties:
                            name:
                              type: string
                            posts:
                              type: integer
                              description: Number of posts tagged with this topic
                                in the last 30 days.
                  unread_notification_count:
                    type: integer
                    description: Cross-app unread notification badge count (piggybacked
                      on every API response).
                    example: 5
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: The token lacks `read:broadcasts` (`insufficient_permissions`),
            or the Communications app is not enabled for the business or not accessible
            to the caller
  "/comms_hub/publications":
    get:
      tags:
      - Comms Hub
      summary: List newsletter publications
      description: |
        The business's **active** newsletter publications (`status: active`),
        ordered by name, paginated. Paused and archived publications are not
        listed (they can still be read by id).

        Answers `403` when an admin turned the Communications app's
        newsletters module off (Settings › "Enable Newsletters",
        `enable_publications`; on by default) — the same rule the web
        Publications page applies. The same applies to every publication and
        issue endpoint.

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. The sign-in tokens hold it; a browser
        session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/CommsHubPerPage"
      responses:
        '200':
          description: One page of active publications
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                - pagination
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CommsHubPublication"
                  pagination:
                    "$ref": "#/components/schemas/CommsHubPagination"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CommsHubPublicationsForbidden"
  "/comms_hub/publications/{id}":
    get:
      tags:
      - Comms Hub
      summary: Get a newsletter publication
      description: |
        One publication of the business, in any status (`active`, `paused`
        or `archived`).

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Publication id.
        schema:
          type: integer
      responses:
        '200':
          description: The publication
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    "$ref": "#/components/schemas/CommsHubPublication"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CommsHubPublicationsForbidden"
        '404':
          description: '`not_found` "Publication not found" — no publication with
            this id in the business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/publications/{publication_id}/issues":
    get:
      tags:
      - Comms Hub
      summary: List a publication's issues
      description: |
        The issues (newsletter editions) of one publication, newest first
        (`created_at` descending), paginated.

        **Which issues are listed.** Issues still in the authoring pipeline
        (`draft`, `in_review`, `scheduled`, `rejected`) are listed only to
        their author, the publication's owner, and people who may publish
        Communications (admins, Communications app admins and the configured
        publisher group). Everyone else sees only issues that shipped
        (`sent`, `archived`) plus the issues they wrote themselves — the same
        rule the web issue page applies.

        **Language.** A sent issue is returned in the caller's language when a
        reviewed translation exists: `lang` if given, else the language the
        caller was sent the issue in, else the caller's preferred language.
        `title` and `preview_text` are then the translated text, and
        `language`, `available_languages` and `source_fingerprint` are present.
        An issue that has not been sent carries none of those three keys.
        One sent issue (`GET …/issues/{id}`) is the reader's edition: with no
        reviewed version in that language, the whole issue translated by AI
        (`translation: ai`), or the original while that translation is made
        (`translation: pending`); `available_languages` is then every language
        it can be read in.

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: publication_id
        in: path
        required: true
        description: Publication id.
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/CommsHubPerPage"
      - name: status
        in: query
        description: 'Only issues in this status. A value outside the enum is ignored
          (the filter is not applied). It never widens what the caller may see: a
          reader who may not see the authoring pipeline gets an empty page for `draft`
          unless they wrote one.'
        schema:
          type: string
          enum:
          - draft
          - in_review
          - scheduled
          - sent
          - archived
          - rejected
      - "$ref": "#/components/parameters/CommsHubIssueLang"
      responses:
        '200':
          description: One page of the issues the caller may read
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                - pagination
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CommsHubIssue"
                  pagination:
                    "$ref": "#/components/schemas/CommsHubPagination"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CommsHubPublicationsForbidden"
        '404':
          description: '`not_found` "Publication not found" — no publication with
            this id in the business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/publications/{publication_id}/issues/{id}":
    get:
      tags:
      - Comms Hub
      summary: Get an issue with its blocks
      description: |
        One issue of the publication, with its content blocks in order.

        An issue the caller may not read (one still in the authoring pipeline
        that they did not write, when they are not its publication's owner and
        may not publish) answers `404 Issue not found`, exactly like a missing
        one.

        **Blocks.** Once the issue was sent, `blocks` is the frozen copy that
        was delivered, in the language chosen as described on the list
        endpoint: each dynamic block (`dynamic_*`) carries the resolved rows
        plus a `heading` and `description`. Before it was sent, `blocks` is
        built from the live blocks — a scheduled or sent block's
        `frozen_payload`, otherwise its editable `data`.

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: publication_id
        in: path
        required: true
        description: Publication id.
        schema:
          type: integer
      - name: id
        in: path
        required: true
        description: Issue id.
        schema:
          type: integer
      - "$ref": "#/components/parameters/CommsHubIssueLang"
      responses:
        '200':
          description: The issue and its blocks
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    "$ref": "#/components/schemas/CommsHubIssueDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CommsHubPublicationsForbidden"
        '404':
          description: '`not_found` — "Publication not found" (no such publication
            in the business) or "Issue not found" (no such issue in it, or one the
            caller may not read).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/audiences":
    get:
      tags:
      - Comms Hub
      summary: List targeting audiences
      description: |
        The business's saved Communications audiences (targeting segments),
        ordered by name, paginated, each with its cached member count.

        Only people who may publish Communications (admins, Communications
        app admins and the configured publisher group) may list audiences —
        the same gate the web Audiences page applies. Everyone else gets
        `403` "You don't have permission to view audiences." The **Audiences**
        module is on unless an admin turned it off (`enable_audiences`).

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/CommsHubPerPage"
      responses:
        '200':
          description: One page of audiences
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                - pagination
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CommsHubAudience"
                  pagination:
                    "$ref": "#/components/schemas/CommsHubPagination"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CommsHubAudiencesForbidden"
  "/comms_hub/audiences/{id}":
    get:
      tags:
      - Comms Hub
      summary: Get a targeting audience
      description: |
        One audience of the business with its cached member count. Same gate
        as the list: only people who may publish Communications.

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Audience id.
        schema:
          type: integer
      responses:
        '200':
          description: The audience
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    "$ref": "#/components/schemas/CommsHubAudience"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/CommsHubAudiencesForbidden"
        '404':
          description: '`not_found` "Audience not found" — no audience with this id
            in the business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/campaigns":
    get:
      tags:
      - Comms Hub
      summary: List the campaigns the caller may put a message in
      description: |
        The choices of the web's Campaign field, for a composer: the
        business's campaigns that are not finished (Draft or Active) and that
        the caller may put messages in — its owner, or any campaign for a
        Communications admin — Active first, then Draft, then by name (at
        most 200; a company has a handful at a time, so it is not paged).
        `can_assign` is false (and `data` empty) for someone with no campaign
        to use; draw no Campaign field for them, and a message they edit keeps
        the campaign it is in. It (and each row's `can_change`) is false too
        for a token without `write:broadcasts`, which every change of a
        campaign asks.

        The campaign a message is already in comes with that message's own
        detail (`campaign` on a feed post, a broadcast or a newsletter issue).
        Put a message in a campaign with `campaign_id` on the feed and
        broadcast create/update, or with
        `PATCH /comms_hub/campaign_membership` for any message.

        `403 campaigns_disabled` "Campaigns are turned off for this
        business." when an admin turned the Campaigns module off
        (`enable_campaigns`).

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The campaigns the caller may use
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                - can_assign
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CommsHubCampaign"
                  can_assign:
                    type: boolean
                    description: Whether to offer a Campaign field at all.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A token without `read:broadcasts`, Communications not enabled
            or not open to the caller, or the Campaigns module turned off (`campaigns_disabled`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/campaigns/{id}":
    get:
      tags:
      - Comms Hub
      summary: One campaign, its goals and the triggers it follows
      description: |
        A campaign as its web page shows it: the triggers it follows, and
        each goal's number with the breakdown it is added up from — every
        sent piece's own count, or why it is not counted. The counted pieces'
        values add up to the goal's `value` (for the completion rate, their
        completions over the people they asked). Opens are counted
        differently per kind of piece — a post: people who opened it in the
        feed; a broadcast: people who viewed it; a newsletter issue: people
        who opened it, recorded only while "Track email opens and clicks" is
        on; a triggered message: people who opened its notification — and
        added up across pieces, so someone who opens three pieces counts
        three times.

        A trigger the campaign follows is ONE piece: every triggered message
        it sent inside the campaign's dates (`from` to `until`), however many.

        Open to every member who can open Communications, as the web page is.
        `followable_triggers` lists the triggers the caller may follow from
        here (empty unless `can_change` and the campaign is not finished).

        **Required Scopes:** `read:broadcasts`. A browser session is asked no
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The campaign
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    "$ref": "#/components/schemas/CommsHubCampaignDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: A token without `read:broadcasts`, Communications not enabled
            or not open to the caller, or the Campaigns module turned off (`campaigns_disabled`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Campaign not found." — no such campaign in the
            business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/campaigns/{id}/follows":
    post:
      tags:
      - Comms Hub
      summary: Follow a trigger
      description: |
        The campaign follows the trigger: every triggered message it sent
        inside the campaign's dates joins the campaign now, and each new one
        joins as it is sent, counting toward the goals with nothing attached
        by hand. A held or queued one joins when it goes out; a suppressed
        or failed one never joins; one already in another campaign stays
        there. While the campaign is finished (Completed or Canceled) new
        ones don't join; reopening it brings in those sent in its dates.
        Moving the campaign's dates re-reads them. The web page's
        Follow, with its rule: the caller must be the campaign's owner or a
        Communications admin, and the campaign must not be finished.

        **Required Scopes:** `write:broadcasts`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - trigger_id
              properties:
                trigger_id:
                  type: integer
                  description: A trigger from the campaign detail's `followable_triggers`.
      responses:
        '201':
          description: Now following
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CommsHubCampaignFollowResult"
        '200':
          description: It already followed that trigger (`changed` false)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CommsHubCampaignFollowResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — not the campaign's owner or a Communications
            admin; `campaigns_disabled`; or a token without `write:broadcasts`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Campaign not found."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`finished` — a Completed or Canceled campaign follows nothing
            new; `trigger_not_found` — no such trigger set up for the business."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/campaigns/{id}/follows/{trigger_id}":
    delete:
      tags:
      - Comms Hub
      summary: Stop following a trigger
      description: |
        The triggered messages this follow put in leave the campaign (the
        messages themselves are kept); any attached by hand stay. Allowed on
        a finished campaign too.

        **Required Scopes:** `write:broadcasts`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: trigger_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Stopped
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    type: object
                    required:
                    - trigger_id
                    - removed
                    properties:
                      trigger_id:
                        type: integer
                      removed:
                        type: integer
                        description: How many triggered messages left the campaign.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — not the campaign's owner or a Communications
            admin; `campaigns_disabled`; or a token without `write:broadcasts`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Campaign not found." or `not_following` — the
            campaign does not follow that trigger.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/campaign_membership":
    patch:
      tags:
      - Comms Hub
      summary: Put a message in a campaign, or take it out
      description: |
        Puts one message — a feed post, a broadcast or a newsletter issue —
        in the campaign `campaign_id` names, or takes it out of its campaign
        (`campaign_id: null`). The campaign's page then lists it and its
        goals count it once it goes out. A newsletter's in-app copy is the
        same communication as its issue: put the issue in the campaign.

        The rule is the web's: the caller must be able to change the campaign
        the message leaves and the one it joins (each one's owner or a
        Communications admin), a campaign it joins must not be finished, and
        the message must be theirs to place — their own, a newsletter issue
        they manage, or one the campaign page lets them attach. A message
        they may not place answers `404`, the same as one that does not
        exist. Their own message from before the calendar listed its kind is
        given its calendar row here, as saving it in Compose would.

        **Required Scopes:** `write:broadcasts`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:broadcasts")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - target_type
              - target_id
              - campaign_id
              properties:
                target_type:
                  type: string
                  enum:
                  - feed
                  - broadcast
                  - issue
                target_id:
                  type: integer
                  description: The post's, broadcast's or newsletter issue's id.
                campaign_id:
                  type: integer
                  nullable: true
                  description: The campaign to put it in; null takes it out of its
                    campaign.
      responses:
        '200':
          description: Where the message is now
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    type: object
                    required:
                    - target_type
                    - target_id
                    - campaign
                    - changed
                    properties:
                      target_type:
                        type: string
                        enum:
                        - feed
                        - broadcast
                        - issue
                      target_id:
                        type: integer
                      campaign:
                        allOf:
                        - "$ref": "#/components/schemas/CommsHubCampaign"
                        nullable: true
                        description: The campaign it is in now; null when none.
                      changed:
                        type: boolean
                        description: False when it was already where it was asked
                          to be.
        '400':
          description: "`parameter_missing` — no `campaign_id` (send null to take
            the message out of its campaign)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`forbidden` — the caller may not add messages to that campaign,
            or may not take the message out of the campaign it is in (the message
            names it); `campaigns_disabled` — the Campaigns module is off (checked
            before the message is looked up). Also a token without `write:broadcasts`
            (`insufficient_permissions`)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Message not found." — no such message in the
            business, or one the caller may not place.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`invalid_target_type`, `invalid_campaign_id`, `campaign_not_found`
            (that campaign no longer exists), `finished` (a Completed or Canceled
            campaign takes no new messages) or `no_item`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/approval_requests":
    get:
      tags:
      - Comms Hub
      summary: The approval requests waiting on the caller ("My queue")
      description: |
        The communications the caller can approve or reject now — the web
        approvals queue's "My queue": requests whose current step is theirs
        (their role, or people, a group or a relationship that resolves to
        them, or any step for a Communications admin). Someone's own request
        is decided by another approver — only a company admin, who is never
        held for approval, sees theirs — and only Communications admins see a
        broadcast's or an alert's. Requests with a deadline come first,
        soonest (most overdue) first, then the rest, newest first. Each row's
        `can_act` is true (false only on a token without `write:broadcasts`).
        Read one with
        `GET /comms_hub/approval_requests/{id}` before deciding it: that is
        where the message is, and the `content_version` the decision doors
        take as `seen`.

        Open to a Communications admin or anyone who reviews in an active
        approval workflow, while Approvals is on.

        **Required Scopes:** `read:broadcasts`. A token without it gets
        `403 insufficient_permissions` before anything else is checked. A
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/CommsHubPerPage"
      responses:
        '200':
          description: The requests waiting on the caller
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                - pagination
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/CommsHubApprovalRequestRow"
                  pagination:
                    "$ref": "#/components/schemas/CommsHubPagination"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`approvals_disabled` "Approvals are disabled." (an admin turned
            Approvals off); `forbidden` "You don''t have access to the approvals queue."
            (the caller reviews in no active workflow and is not a Communications
            admin). Also a token without `read:broadcasts` (`insufficient_permissions`),
            or Communications not enabled or not open to the caller.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/approval_requests/{id}":
    get:
      tags:
      - Comms Hub
      summary: One approval request, with the message as its readers will see it
      description: |
        The web approval request page as data: the request — its workflow,
        every step and who decides it, the deadline, the decisions so far
        with who made them and why, what the caller can do with it now
        (`can_act`, `can_reject`, `can_withdraw`) — the distribution facts the
        page lists (`facts`: who
        receives it, through which channels, when), and `communication`, the
        message as its readers will see it:

        - a **post** (`type: post`) in the fields `GET /feeds/{id}` gives
          them: `title`, `body` (Markdown, as written — never a translation),
          `rendered_body` (HTML, its pictures signed now), `cover_image`,
          `media` (signed URLs, valid for an hour — read the request again
          for fresh ones), `inline_media_ids`, `topics`, `poll` with its
          choices, and its kind, Must-Read, acknowledgement and expiry. A
          preview: nothing in it is reacted to, voted on or acknowledged.
          Its pictures go to whoever may read the post and to whoever decides
          or decided a request about it; anyone else who may open the request
          (the rest of the reviewer queue) gets the words with
          `pictures_shown: false`, `media: []`, no `cover_image`,
          `inline_media_ids: []` and the pictures taken out of `body` and
          `rendered_body`.
        - a **broadcast** (`type: broadcast`) as its reader view draws it, in
          the fields `GET /broadcasts/{id}` gives them: `title`, `description`,
          `hero_image`, `attachments`, its channels and timing.
        - a **newsletter** issue (`type: newsletter`): `title`,
          `preview_text`, its publication, and `web_url` — the request's own
          web page, which draws every block as the recipient's email will.
        - anything else (an emergency alert, a survey, a contest, a triggered
          message): its `type`, `title` and `status`; the facts say the rest.

        `communication` is null when the message has been deleted.

        Who may open it is the web page's rule: while Approvals is on, a
        Communications admin, anyone who reviews in an active workflow, the
        person who asked, whoever decides it now and whoever decided it — a
        broadcast's or an alert's request only Communications admins and its
        requester. A decided request about a message that has not gone out
        stays with its preparers, its requester and its deciders. While
        Approvals is off, only the person who asked. The approval notice's
        "Open request" link (`/apps/communications/approvals/{id}`) names the same
        id.

        Reads only. Decide it — whatever kind of message it is — at
        `POST /approvals/{id}/approve`, `/reject` and `/withdraw`, with
        `request_id` and `content_version` (as `seen`) from here.

        **Required Scopes:** `read:broadcasts`. A browser session is asked no
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The request
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    "$ref": "#/components/schemas/CommsHubApprovalRequestDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`forbidden` "You don''t have access to the approvals queue."
            — the caller is none of the people above; "You don''t have access to that
            approval request." — a broadcast''s or an alert''s request, for someone
            who is neither a Communications admin nor its requester; or "That post
            hasn''t gone out yet — only the people preparing it can open this request."
            (the kind is named) — a decided request about a message not yet sent,
            for someone who neither asked nor decided. `no_longer_assigned` — the
            request was sent to the caller, who can no longer decide it: "Your step
            was already decided — the request is with the next approver now.", "This
            request was already approved — there''s nothing left for you to decide."
            (rejected, withdrawn), or, edited since, "It was edited after it was sent
            for review — this is the current version." (the web opens the version
            waiting now). `approvals_disabled` "Approvals are disabled." — Approvals
            is off and the caller did not ask. Also a token without `read:broadcasts`,
            or Communications not enabled or not open to the caller.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Approval request not found." — no such request
            in the business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comms_hub/approval_preview":
    post:
      tags:
      - Comms Hub
      summary: Will Send hold this draft for approval, and who is asked
      description: |
        Compose's "Goes to … for approval." note and the phone's "Send for
        review" button, for a message still being written: whether Send will
        hold it for approval, under which workflow, and who the workflow's
        first step asks. The same answer desktop and phone Compose give for
        the same draft — the send's own gate asked with the message's kind
        (a Post's `content_type` picks the workflow for questions or polls),
        its words (`title` folds in as the first line, as the send writes
        it), its #topics and topic chips, whom it reaches (a workflow limited
        by whom a message reaches is judged against it) and whose it is.

        A draft being sent (`feed_id` for a post, announcement or must-read;
        `broadcast_id` for a broadcast or a spot-only message) is its
        author's: an app admin sending a teammate's draft is told the
        teammate's workflow and approvers, as Send applies them. Only a draft
        the caller may send is read (404 otherwise). An admin, whom no
        workflow holds, gets `will_hold: false`. Nothing is saved.

        The approvers never include the people the request would bar from
        deciding it (its author, the sender). `note` and `send_label` are in
        the caller's display language.

        **Required Scopes:** `write:broadcasts`. A browser session is asked no
        scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum:
                  - post
                  - announcement
                  - must_read
                  - break_through
                  - in_context
                  default: post
                  description: The message's kind; `break_through` is the Broadcast
                    kind.
                content_type:
                  type: string
                  enum:
                  - update
                  - question
                  - poll
                  description: A Post's Kind of post (default `update`, or the draft's
                    own).
                title:
                  type: string
                body:
                  type: string
                  description: The words as written,
                author_topics:
                  type: array
                  items:
                    type: string
                  description: Topic chips by name, as `POST /feeds` takes them.
                topic_ids:
                  type: array
                  items:
                    type: integer
                  description: Topic chips by id (added to `author_topics`).
                rejected_topics:
                  type: array
                  items:
                    type: string
                  description: "#topics in the words the author took off, as `POST
                    /feeds` takes them."
                author_topics_listed:
                  type: boolean
                  description: "`author_topics` is all of the draft's topics (as `PATCH
                    /feeds/{id}` takes it)."
                feed_id:
                  type: integer
                  description: The unsent post being sent (draft or scheduled).
                broadcast_id:
                  type: integer
                  description: The unsent broadcast being sent (draft or scheduled).
                audience_type:
                  type: string
                  enum:
                  - everyone
                  - segments
                audience_segment_ids:
                  type: array
                  items:
                    oneOf:
                    - type: integer
                    - type: string
                  description: Saved segments (and `direct_reports`), as `POST /feeds`
                    takes them; only those the caller is offered count.
                extra_user_ids:
                  type: array
                  items:
                    type: integer
                audience_criteria:
                  type: array
                  items:
                    type: object
                    additionalProperties: true
                  description: Attribute targeting, in the shapes `POST /feeds` documents.
                scheduled_at:
                  type: string
                  format: date-time
                  description: A future time makes `send_label` "Schedule" when nothing
                    holds it.
      responses:
        '200':
          description: The answer
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    type: object
                    required:
                    - kind
                    - will_hold
                    - workflow
                    - approvers
                    - approver_count
                    - note
                    - send_label
                    properties:
                      kind:
                        type: string
                      will_hold:
                        type: boolean
                        description: Send submits it for approval instead of sending
                          it.
                      workflow:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                      approvers:
                        type: array
                        description: The first 25 people the first step asks.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                      approver_count:
                        type: integer
                      note:
                        type: string
                        nullable: true
                        description: '"Goes to Ruth Okafor for approval." — Compose''s
                          note; null when nothing holds it.'
                      send_label:
                        type: string
                        description: '"Send for review" when held, else "Schedule"
                          for a future send, else "Post" (feed kinds) or "Send".'
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`kind_not_available` "You can''t send this kind of message."
            — the caller may not send that kind. Also a token without `write:broadcasts`
            (`insufficient_permissions`), or Communications not enabled or not open
            to the caller.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Draft not found." — `feed_id` / `broadcast_id`
            names no unsent draft the caller may send.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`invalid_kind` — `kind` is not one of the five."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/company-store/config":
    get:
      tags:
      - Company Store
      summary: Company Store bootstrap configuration and capabilities
      description: |
        Called **once** when the module opens. Returns the tenant's Company Store
        configuration as *this caller* experiences it, so a client can stop
        hardcoding anything tenant-specific and can gate the manager UI.

        ### The store kill-switch is reported, not enforced

        `store_enabled` is `false` when an admin has paused the store. **This
        endpoint still answers 200** — it is the only one in the namespace that
        does. Every screen endpoint (`/catalog`, `/dashboard`, `/points`,
        `/orders`) continues to answer `403 store_disabled`, which is what makes
        the split useful: a client learns the state here and renders "your
        organization paused the store" rather than showing an empty catalog or a
        generic auth error.

        A paused store reports an **empty** `categories` and `payment_methods` —
        nothing is browsable and nothing is purchasable while it is off — and the
        category count query is skipped rather than charged to every request for
        the duration of the pause.

        ### Gating the manager UI

        Role appears **exactly once**, in `viewer`, and it is the namespace's
        shared card. `viewer.is_manager` is
        `Store::RedemptionApprovalService.can_approve_redemptions?` — the very
        predicate the approvals queue's own door gates on — so a client that hides
        the Approvals tab on this flag never draws a tab that bounces. It is true
        for a designated approver-group member, and for a line manager with direct
        reports when no group is configured; it is **not** a `role: manager`
        membership check.

        `viewer.is_store_admin` is a business admin-or-above or a Company Store
        app-admin, and is what offers the item / order management surfaces.

        There is deliberately **no** second `permissions` block restating those
        two booleans — a parallel spelling is how one surface starts disagreeing
        with another about who may approve.

        ### Categories — the filter sheet, from one source

        `categories` powers the catalog **category-filter bottom sheet**. The
        first row is always **All Categories** (`key: null`) carrying the true
        total; then one row per **enabled** category, *including the ones at 0*,
        so the sheet does not reshuffle as a tenant's stock moves (`has_items`
        exists for a client that wants the web dropdown's behaviour of hiding
        empty rows).

        Two things to get right client-side:

        * **`key` is the value `?category=` takes verbatim**, not the
          merchandising label — the `StoreItem` column vocabulary: `swag`,
          `gift_card`, `experience`, `charitable`. The labels deliberately differ
          from the keys (`charitable` is merchandised as **"Donate"**, and the
          other three are pluralised), which is why both are sent.
        * **`icon` is a stable FontAwesome-style key**, never an image URL —
          `tshirt`, `gift`, `star`, `hand-holding-heart`, with `tag` on the All
          row and `box` as the fallback. These are the same keys the web
          storefront renders, from
          `Apps::CompanyStoreHelper#company_store_category_icon`, so an icon
          change lands on every surface at once.

        A category the admin switched off is **absent** from the array entirely —
        it is not sent with `count: 0`. (`count: 0` means the category is on and
        has no stock right now, which is a different thing and wants different
        copy.)

        ### Payment methods — the tenant's ceiling

        `payment_methods` lists **only the methods the tenant actually accepts**,
        in the order a client should offer them. Render the array verbatim rather
        than filtering it: a method a client has never heard of still appears, and
        one the admin switched off cannot.

        | `key` | `label` | when it appears |
        |---|---|---|
        | `points` | Points | `points_redemption_enabled` |
        | `cash` | Card | `cash_purchases_enabled` |
        | `mixed` | Points + Card | **all three** of the above plus `mixed_payments_enabled` |

        The `mixed` rule is the one that bites. `features.mixed_payments_enabled`
        is the raw admin toggle; a split payment can only be *taken* when the
        store can take both halves, so a tenant with the mixed toggle on but card
        purchases off gets **no** `mixed` method — exactly as the item detail
        sheet's `payment_options` refuses it. A client that reads the raw feature
        flag instead of this array will draw a split-payment button whose checkout
        bounces.

        This is the **tenant's ceiling**, not a per-item answer: an individual
        item may be points-only or card-only, and
        `GET /company-store/catalog/{id}` carries the per-item `payment_options`
        with its own refusal reason for each. No item can ever offer a method
        outside this array.

        ### The currency noun

        `currency_label` (`"points"`) and `currency_label_singular` (`"point"`)
        are sent so no client hardcodes the word. Both are constants today —
        there is no per-tenant setting for them yet — but serving them from here
        means the day one appears, no client needs a release. The singular is
        carried alongside the plural because a client rendering "1 points" has no
        way to derive it.

        ### Redemption rules — disclose before, don't surprise at checkout

        Every figure in `redemption` is **null when off**, never `0`, so a client
        never renders "cap: 0" as "you may redeem nothing".

        * `approval_threshold_points` — the points figure **at or above** which a
          redemption is HELD for approval instead of spending immediately
          (checkout compares `>=`). It is the **lowest enabled** of the admin and
          manager tiers, because that is the one that actually holds; a tier set
          to `0` is off and is ignored. `null` = no tier enabled.
        * `monthly_points_cap` / `monthly_points_remaining` — the per-user
          calendar-month ceiling and what is left of it for **this caller**. The
          remaining figure counts the same set `Store::CheckoutService` counts
          (points and mixed orders, cancelled and refunded excluded), and is
          floored at 0. The query behind it is skipped entirely when no cap is
          configured — which is every tenant that never set one.
        * `velocity_*` — the short-window fraud brake. A breach does **not** block
          the redemption, it forces the approval hold, so disclose it as "this may
          need approval" rather than as a refusal. `velocity_window_hours` reports
          the service's own 24h fallback when unset.

        ### Points

        `points` is the caller's **own** wallet, for every role — there is no
        persona branch here. Keys are the plain nouns a config payload reads best
        with (`balance`, `pending`, `lifetime_earned`, `lifetime_spent`); the
        namespace's `balance_card` spelling (`points_balance`, `pending_points`,
        …) is still served verbatim by every screen endpoint.

        `expiring_points` is what **newly** expires within `expiring_within_days`,
        never the whole expirable pool. Read it with
        `features.points_expiry_enabled`: `0` means "nothing is close" when expiry
        is on, and "this tenant does not expire points" when it is off — the
        second should render no expiry banner at all.

        ### Regions

        `region` is the region this payload was scoped to (`null` when regions are
        off, or when they are on and this user resolves to none — in which case
        the pool narrowed to global items; `features.regions_enabled`
        distinguishes the two). `available_regions` is the vocabulary for a
        client's own picker, with `active` marking the resolved one. Passing
        `?region_id=` scopes the category counts to a sibling region, exactly like
        the web region picker.

        ### Query budget

        Constant. Nothing in this payload scales with the number of categories,
        items, regions or people — the whole category sheet is ONE grouped query,
        and the two per-caller figures that need their own query (the expiring
        slice and this month's committed spend) are skipped entirely unless the
        tenant configured that feature.
      security:
      - BearerAuth: []
      parameters:
      - name: region_id
        in: query
        required: false
        description: Scope the category counts to a sibling region, like the web region
          picker. Ignored when the tenant has no active regions. An id that is not
          an active region of this tenant falls back to the caller's own resolved
          region rather than erroring.
        schema:
          type: integer
      responses:
        '200':
          description: 'The tenant''s Company Store configuration as this caller experiences
            it. Also the answer for a PAUSED store, reported as `store_enabled: false`
            rather than as a 403.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    type: object
                    required:
                    - store_enabled
                    - store_label
                    - currency_label
                    - currency_label_singular
                    - viewer
                    - features
                    - region
                    - available_regions
                    - categories
                    - payment_methods
                    - points
                    - redemption
                    properties:
                      store_enabled:
                        type: boolean
                        description: The tenant's store kill-switch. False = an admin
                          paused the store; `categories` and `payment_methods` are
                          then empty and every screen endpoint answers 403 `store_disabled`.
                          Reported rather than enforced — see the endpoint description.
                      store_label:
                        type: string
                        description: The app's own name, so a console rename needs
                          no client release. Defaults to "Company Store".
                        example: Company Store
                      currency_label:
                        type: string
                        description: What the tenant calls a unit of store currency
                          (plural).
                        example: points
                      currency_label_singular:
                        type: string
                        description: The singular form, sent because a client rendering
                          "1 points" has no way to derive it.
                        example: point
                      viewer:
                        "$ref": "#/components/schemas/CompanyStoreConfigViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStoreConfigFeatures"
                      region:
                        "$ref": "#/components/schemas/CompanyStoreConfigRegion"
                      available_regions:
                        type: array
                        description: Every active region, for a client's own picker.
                          Empty when the tenant has configured none.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreConfigAvailableRegion"
                      categories:
                        type: array
                        description: The catalog category-filter sheet — "All Categories"
                          first, then one row per ENABLED category. Byte-for-byte
                          the same rows GET /company-store/catalog/categories returns.
                          Empty while the store is paused.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreConfigCategory"
                      payment_methods:
                        type: array
                        description: The methods this TENANT accepts, in offer order.
                          Only enabled methods appear — render verbatim rather than
                          filtering. Empty while the store is paused, or when the
                          tenant takes neither points nor cash.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreConfigPaymentMethod"
                      points:
                        "$ref": "#/components/schemas/CompanyStoreConfigPoints"
                      redemption:
                        "$ref": "#/components/schemas/CompanyStoreConfigRedemption"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
              example:
                config:
                  store_enabled: true
                  store_label: Company Store
                  currency_label: points
                  currency_label_singular: point
                  viewer:
                    id: 42
                    name: Anup Kumar
                    image: https://officechat.workforce.mangoapps.com/rails/active_storage/…
                    is_manager: true
                    is_store_admin: false
                  features:
                    points_redemption_enabled: true
                    cash_purchases_enabled: false
                    mixed_payments_enabled: false
                    recognition_integration_enabled: true
                    regions_enabled: false
                    points_per_dollar: 100
                    points_expiry_enabled: false
                  region:
                  available_regions: []
                  categories:
                  - key:
                    label: All Categories
                    icon: tag
                    color: secondary
                    count: 34
                    active: true
                    has_items: true
                  - key: swag
                    label: Swag
                    icon: tshirt
                    color: primary
                    count: 14
                    has_items: true
                    active: false
                  - key: gift_card
                    label: Gift Cards
                    icon: gift
                    color: success
                    count: 9
                    has_items: true
                    active: false
                  - key: experience
                    label: Experiences
                    icon: star
                    color: info
                    count: 5
                    has_items: true
                    active: false
                  - key: charitable
                    label: Donate
                    icon: hand-holding-heart
                    color: warning
                    count: 6
                    has_items: true
                    active: false
                  payment_methods:
                  - key: points
                    label: Points
                    description: Redeem with your points balance
                  points:
                    balance: 4200
                    pending: 300
                    lifetime_earned: 12000
                    lifetime_spent: 7800
                    last_earned_at: '2026-08-19T10:14:02Z'
                    last_spent_at: '2026-07-30T16:02:44Z'
                    points_per_dollar: 100
                    expiring_points: 0
                    expiring_within_days: 14
                  redemption:
                    approval_threshold_points: 5000
                    monthly_points_cap:
                    monthly_points_remaining:
                    velocity_window_hours: 24
                    velocity_max_orders:
                    velocity_max_points:
                unread_notification_count: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            `insufficient_permissions` — the token lacks `read:company_store`.
            Checked first, before the app gate, so this answer can precede
            `access_denied`.

            `access_denied` — the Company Store app isn't enabled for this tenant,
            or this user is outside the app's audience.

            Note this endpoint does **not** answer `store_disabled`. A paused
            store is a 200 with `store_enabled: false`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                      message:
                        type: string
  "/company-store/dashboard":
    get:
      tags:
      - Company Store
      summary: Company Store dashboard
      description: |
        Everything the Dashboard tab renders in ONE request — the native mirror
        of the web Company Store dashboard (`/apps/company-store`) and of the
        Dashboard tab in the Company Store Mobile design.

        Every number and list comes from the same query object the web page uses
        (`Store::DashboardStats`), so the API cannot drift from the page: one
        pool for every grid and count (region + category-enablement + audience
        filters), memoized readings, and lazy invocation so an employee request
        never runs the manager-only approval queries.

        ### What is always present

        `viewer`, `features`, `region`, `balance`, `stats`, `featured_items`,
        `featured_absorbed`, `saved_items`, `points_activity` and
        `recent_orders`.

        ### What is conditional

        Keys that are gated off are **absent**, not null — exactly as the web
        page renders no card at all. `viewer` and `features` tell a client which
        shape it received, so it never has to infer a disabled surface from a
        missing key.

        * **`within_reach`** — the affordability carousel. Present only when the
          surface exists (points redemption on, and a balance to spend). An
          EMPTY array then means "nothing cheap enough yet", which is a
          different message from a tenant that does not redeem points at all.
        * **`earn_onramp`** — the first-run "how to earn" card. Present only for
          a caller with nothing earned, nothing pending and nothing to spend,
          AND only when Recognitions is actually reachable for them — its CTA
          deep-links there, so a link that would bounce is not offered.
        * **`how_to_earn`** — the explanation-only twin of `earn_onramp`: the same
          first-run caller, when the Recognition button cannot be honoured
          (Recognitions is not reachable, or the store's recognition integration
          is off). `headline` and `body` only — there is no link to follow. It is
          present exactly where `earn_onramp` is absent for a first-run caller,
          so a client draws at most one of the two.
        * **`team_approvals`** — the design's manager-only widget. Present only
          for a caller who actually HAS a redemption approval queue, which is the
          same rule (`Store::RedemptionApprovalService.can_approve_redemptions?`)
          the queue page's own gate and the app nav read, so this widget never
          counts holds that page would refuse. It is also exactly what
          `viewer.is_manager` reports.

        `featured_absorbed` is true when the Featured grid came back empty ONLY
        because "Within reach" is already showing those items — a client's empty
        state can then say where they went instead of claiming there are none.
      operationId: companyStoreDashboard
      security:
      - BearerAuth: []
      parameters:
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: Scope the pool to a sibling region, exactly like the web region
          picker. Ignored when the tenant has no active regions. The pool is always
          the selected region PLUS the global items.
      responses:
        '200':
          description: The dashboard
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    properties:
                      viewer:
                        "$ref": "#/components/schemas/CompanyStoreViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStoreFeatures"
                      region:
                        "$ref": "#/components/schemas/CompanyStoreRegion"
                      balance:
                        allOf:
                        - "$ref": "#/components/schemas/CompanyStoreBalance"
                        - type: object
                          properties:
                            expiring_points:
                              type: integer
                              description: What NEWLY expires inside `expiring_within_days`.
                                0 when the tenant does not expire points.
                            expiring_within_days:
                              type: integer
                              description: The same horizon the web banner and the
                                warning notification use, so the number never disagrees
                                with the copy beside it.
                      stats:
                        type: object
                        description: The four tappable stat tiles.
                        properties:
                          available_items:
                            type: integer
                          total_orders:
                            type: integer
                          lifetime_earned:
                            type: integer
                          lifetime_spent:
                            type: integer
                      featured_items:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStoreItemCard"
                      featured_absorbed:
                        type: boolean
                      within_reach:
                        type: array
                        description: ABSENT when points redemption is off or there
                          is no balance to spend. Empty means "nothing cheap enough
                          yet".
                        items:
                          "$ref": "#/components/schemas/CompanyStoreItemCard"
                      saved_items:
                        type: object
                        description: The wishlist preview plus its TRUE total, so
                          "+N more saved" can be accurate.
                        properties:
                          total:
                            type: integer
                          items:
                            type: array
                            items:
                              "$ref": "#/components/schemas/CompanyStoreItemCard"
                      points_activity:
                        type: object
                        description: "`period_days` is reported rather than assumed,
                          so a client's header cannot claim a window the server did
                          not compute."
                        properties:
                          period_days:
                            type: integer
                          earned:
                            type: integer
                          spent:
                            type: integer
                          adjustments:
                            type: integer
                          refunded:
                            type: integer
                            description: Points cancelled or declined orders gave
                              back (positive).
                          expired:
                            type: integer
                            description: Points the expiry policy took, as a positive
                              magnitude.
                          transaction_count:
                            type: integer
                      recent_orders:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStoreOrderRow"
                      earn_onramp:
                        type: object
                        description: ABSENT unless this is a first-run caller AND
                          Recognitions is reachable for them.
                        properties:
                          headline:
                            type: string
                          body:
                            type: string
                          recognition_url:
                            type: string
                      how_to_earn:
                        type: object
                        description: ABSENT unless this is a first-run caller AND
                          `earn_onramp` is absent. Explanation only, no link.
                        properties:
                          headline:
                            type: string
                          body:
                            type: string
                      team_approvals:
                        type: object
                        description: ABSENT unless `viewer.is_manager`. `pending_count`
                          is the FULL queue depth, not the feed length — the feed
                          is capped and a "View All" needs the real number.
                        properties:
                          pending_count:
                            type: integer
                          queue_url:
                            type: string
                          requests:
                            type: array
                            items:
                              "$ref": "#/components/schemas/CompanyStoreApprovalRequest"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:company_store`
            (checked first, before the app gate). `access_denied` — the Company Store
            app is not accessible to this caller. `store_disabled` — the tenant's
            admin has switched the store off."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
  "/company-store/catalog":
    get:
      tags:
      - Company Store
      summary: Browse the catalog
      description: |
        The catalog grid — the native mirror of the web catalog
        (`/apps/company-store/catalog`) and its `/m/` twin, and of the Catalog
        and Featured Items screens in the Company Store Mobile design.

        One request returns everything the screen needs: the page of items, the
        whole filter sheet (category rows with counts, collection chips, the
        sort menu), the caller's wallet, the tenant's payment toggles, the
        resolved region and the region picker's options. A client should not
        need a second call to render the screen.

        ### Filtering, sorting and paging

        Every filter is optional and they all compose. `category`, `sort`,
        `collection`, `search`, `region_id` and `page`/`per_page` behave exactly
        as they do on the web page, because the same query object applies them.

        An unrecognised `category` or `sort` is **dropped**, not rejected and
        not left to silently empty the grid — and `filters.applied` reports what
        was actually applied, so a client can see its parameter was ignored.

        `collection` is the exception, because collections are free-form
        per-tenant tags with no fixed vocabulary to validate against: an
        unknown one is **applied** and returns an empty grid rather than being
        dropped, and `filters.applied.collection` echoes it back. Read
        `filters.collections` for the collections that actually hold an item.

        A non-numeric `page` is page 1; a non-numeric or zero `per_page` falls
        back to the default of 20 rather than to 1.

        `featured=true` is **not** the same as `sort=featured`: the filter
        returns only featured items (the design's Featured Items screen), while
        the sort orders featured rows first and still returns everything.

        `within_balance=true` narrows to the items the caller can redeem
        outright with points (a points price at or under their balance), as the
        web page's "Within my balance" toggle does. `within_balance_count` is
        how many items that toggle would show under the other active filters,
        whether or not it is on.

        ### Two sets of counts: `filters` and `filter_counts`

        `filters` is the filter sheet the listing shares with
        `GET /company-store/catalog/categories`: its category rows and
        collection chips count under region, audience, category enablement and
        `search` only — not the collection and not `within_balance`.

        `filter_counts` carries the same category rows and collection chips
        (same keys, same row shape) counted the way the web and phone catalogs
        count their pills: each category row under the active `collection` and
        `within_balance` as well, each collection chip under `within_balance`
        (the chips are alternatives to one another, so the active collection is
        not applied to them). Its "All Categories" row is what the grid holds.
        It follows `featured` too, as the grid does. With none of these on, it
        equals `filters.categories` / `filters.collections`; `featured` never
        narrows `filters`.

        ### What is in the pool

        Only `available` items — active, and either unlimited inventory or some
        left. On top of that, three narrowings apply to the grid AND to every
        count in `filters` and `filter_counts`, because a count drawn from a
        wider pool advertises results the grid cannot produce:

        * **Category enablement** — an admin can switch a whole category off.
          Experiences and Donate are OFF out of the box.
        * **Region** — when the tenant has active regions, the pool is the
          requested (or the caller's own resolved) region PLUS the global items.
        * **Audience targeting** — an item restricted to a recipient group is
          absent for everyone outside it, and 404s on deep link.

        ### Prices are gated, not just reported

        `points_price` is present only while points redemption is on AND the
        item carries one; `cash_price` only while card purchases are on (which
        is OFF by default) AND the item carries one. An item with no open
        payment path reports both as `null` and `price_label` as
        `"Not currently redeemable"`. This is the same rule the web card
        applies, and it exists so an item can never advertise a price the store
        would refuse to take.

        ### `can_quick_redeem`

        True only when the server would actually accept a one-tap redemption:
        points on, a points price, an enabled category, in stock, no variants to
        pick, no address to collect, no engraving to collect, and affordable on
        today's balance. It is the same guard set the redeem endpoint enforces,
        so a client that trusts it never shows a button that bounces.
      operationId: browseCompanyStoreCatalog
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        required: false
        schema:
          type: string
          enum:
          - swag
          - gift_card
          - experience
          - charitable
        description: Narrow to one category. An unrecognised value is dropped. A category
          the tenant has switched off yields an empty grid, which is the same answer
          the web page gives.
      - name: collection
        in: query
        required: false
        schema:
          type: string
        description: Narrow to one merchandised collection ("travel", "5-year-anniversary",
          …). Case- and whitespace-insensitive, matching how collections are stored.
      - name: featured
        in: query
        required: false
        schema:
          type: boolean
        description: Featured items only — the design's Featured Items screen.
      - name: within_balance
        in: query
        required: false
        schema:
          type: boolean
        description: Items the caller can redeem outright with points only — the web
          page's "Within my balance" toggle. Applied to `filter_counts`, not to `filters`.
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Free text over item name and description. A whitespace-only term
          is treated as no search. Also applied to every count in `filters` and `filter_counts`,
          because each filter row preserves the active term.
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - featured
          - popular
          - price_low
          - price_high
          - name
          - newest
          default: featured
        description: The same six strategies the desktop sort dropdown offers. `popular`
          ranks by redemptions plus purchases. An unrecognised value falls back to
          `featured`.
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: Browse a sibling region, exactly like the web region picker.
          Ignored when the tenant has no active regions, or when the id is unknown
          (in which case the caller's own resolved region is used). The pool is always
          the selected region PLUS the global items.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        description: Clamped to 50. A non-numeric or zero value falls back to 20.
      responses:
        '200':
          description: A page of the catalog, with the filter sheet and the caller's
            context
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    allOf:
                    - "$ref": "#/components/schemas/CompanyStoreContext"
                    - type: object
                      properties:
                        filters:
                          "$ref": "#/components/schemas/CompanyStoreFilters"
                        filter_counts:
                          type: object
                          description: 'The category rows and collection chips of
                            `filters`, counted under the filters in force: a category
                            row under the active `collection` and `within_balance`,
                            a collection chip under `within_balance`. The counts the
                            web and phone catalog pills show. Equal to `filters.categories`
                            / `filters.collections` when neither filter is on.'
                          properties:
                            categories:
                              type: array
                              items:
                                "$ref": "#/components/schemas/CompanyStoreCategoryRow"
                            collections:
                              type: array
                              items:
                                "$ref": "#/components/schemas/CompanyStoreCollectionChip"
                        within_balance_count:
                          type: integer
                          description: How many items `within_balance=true` shows
                            under the other active filters, whether or not it is on.
                        items:
                          type: array
                          items:
                            "$ref": "#/components/schemas/CompanyStoreItemCard"
                        meta:
                          "$ref": "#/components/schemas/CompanyStorePageMeta"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
              example:
                catalog:
                  viewer:
                    id: 412
                    name: Priya Raman
                    image: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/avatar.jpg
                    is_manager: false
                    is_store_admin: false
                  features:
                    points_redemption_enabled: true
                    cash_purchases_enabled: false
                    mixed_payments_enabled: false
                    recognition_integration_enabled: true
                    regions_enabled: true
                    points_per_dollar: 100
                  region:
                    id: 3
                    name: United States
                    country_code: US
                  balance:
                    points_balance: 6250
                    pending_points: 300
                    lifetime_points_earned: 24800
                    lifetime_points_spent: 18550
                    last_earned_at: '2026-08-15T08:00:00Z'
                    last_spent_at: '2026-08-18T10:19:00Z'
                  redemption:
                    approval_threshold_points: 5000
                  available_regions:
                  - id: 3
                    name: United States
                    country_code: US
                    active: true
                  - id: 4
                    name: United Kingdom
                    country_code: GB
                    active: false
                  filters:
                    applied:
                      category: swag
                      collection:
                      featured: false
                      within_balance: true
                      search:
                      sort: featured
                      region_id: 3
                    sort_options:
                    - key: featured
                      label: Featured
                      active: true
                    - key: popular
                      label: Most Popular
                      active: false
                    - key: price_low
                      label: 'Points: Low to High'
                      active: false
                    - key: price_high
                      label: 'Points: High to Low'
                      active: false
                    - key: name
                      label: Name A-Z
                      active: false
                    - key: newest
                      label: Newest
                      active: false
                    categories:
                    - key:
                      label: All Categories
                      icon: tag
                      color: secondary
                      count: 20
                      active: false
                      has_items: true
                    - key: swag
                      label: Swag
                      icon: tshirt
                      color: primary
                      count: 9
                      active: true
                      has_items: true
                    - key: gift_card
                      label: Gift Cards
                      icon: gift
                      color: success
                      count: 6
                      active: false
                      has_items: true
                    collections:
                    - key: travel
                      label: Travel
                      count: 4
                      active: false
                  filter_counts:
                    categories:
                    - key:
                      label: All Categories
                      icon: tag
                      color: secondary
                      count: 13
                      active: false
                      has_items: true
                    - key: swag
                      label: Swag
                      icon: tshirt
                      color: primary
                      count: 6
                      active: true
                      has_items: true
                    - key: gift_card
                      label: Gift Cards
                      icon: gift
                      color: success
                      count: 5
                      active: false
                      has_items: true
                    collections:
                    - key: travel
                      label: Travel
                      count: 3
                      active: false
                  within_balance_count: 6
                  items:
                  - id: 1
                    name: Company Logo Hoodie
                    description: Soft fleece pullover hoodie with an embroidered company
                      logo.
                    category: swag
                    category_label: Swag
                    category_icon: tshirt
                    category_color: primary
                    collection:
                    image_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                    featured: true
                    status: active
                    status_label: Active
                    available: true
                    low_stock: false
                    inventory_count:
                    requires_shipping: true
                    region:
                      id: 3
                      name: United States
                    points_price: 4500
                    cash_price:
                    price_label: 4500 pts
                    redeemable_with_points: true
                    purchasable_with_cash: false
                    url: https://acme.workforce.mangoapps.com/apps/company-store/catalog/1
                    affordable: true
                    points_to_go: 0
                    progress_percent: 100
                    ready_to_redeem: true
                    stock_label: In stock
                    stock_detail_label: In stock
                    has_variants: true
                    variant_types:
                    - size
                    - color
                    digital_delivery: false
                    personalizable: false
                    wishlisted: false
                    can_quick_redeem: false
                  meta:
                    current_page: 1
                    per_page: 20
                    total_count: 6
                    total_pages: 1
                    has_next_page: false
                    has_prev_page: false
                unread_notification_count: 3
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:company_store`
            (checked first, before the app gate). `access_denied` — the Company Store
            app is not accessible to this caller (not enabled for the tenant, or the
            caller is outside the app's visibility rules). `store_disabled` — the
            tenant's admin has switched the store off."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
              examples:
                store_disabled:
                  value:
                    error:
                      code: store_disabled
                      message: The Company Store is currently disabled
  "/company-store/catalog/categories":
    get:
      tags:
      - Company Store
      summary: Catalog filter sheet
      description: |
        The category sheet on its own, so a client can open the filter without
        pulling a page of items — the same read split
        `GET /recognitions/leaderboard/categories` makes.

        Returns the **same** `filters` block the listing returns, built from the
        same code, so the two can never disagree about a count. `search` and
        `region_id` are honoured for the reason the web filter rows honour them:
        every row preserves the active term, so a count computed without it
        would advertise results the filtered grid cannot produce. Like the
        listing's `filters`, its counts do not follow the collection or "Within
        my balance"; the counts under those are the listing's `filter_counts`.

        The shared context blocks (`viewer`, `features`, `region`, `balance`,
        `redemption`, `available_regions`) ride along, which makes this a
        reasonable "open the store" first call for a client that wants the
        filter sheet before the first grid. There is no `items` or `meta`.
      operationId: companyStoreCatalogCategories
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Scope the counts to a search term, as the grid's own rows do.
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: Scope the counts to a region.
      responses:
        '200':
          description: The filter sheet
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    allOf:
                    - "$ref": "#/components/schemas/CompanyStoreContext"
                    - type: object
                      properties:
                        filters:
                          "$ref": "#/components/schemas/CompanyStoreFilters"
                  unread_notification_count:
                    type: integer
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions`, `access_denied` or `store_disabled`
            — see the listing endpoint."
  "/company-store/catalog/{id}":
    get:
      tags:
      - Company Store
      summary: One catalog item
      description: |
        Everything the item detail screen renders: the card's own fields plus the
        photo strip, the variant groups, every payment method with its own
        refusal reason, the web page's Item Details block, the caller's
        wishlist / back-in-stock watch state, and the related-items strip.

        ### Unavailable items are served on purpose

        An out-of-stock, coming-soon, discontinued or draft item returns **200**,
        exactly as the web detail page renders it — with a status alert instead
        of the buy controls. Read `available`, `status`, `status_label`,
        `stock_label` and `payment_options[].block_reason` and render the same
        way. Hiding the item would break a deep link from an order, a
        notification or a wishlist.

        Region and audience restrictions DO refuse, because those are disclosure
        boundaries rather than states:

        * **audience** → `404 not_found`, with copy that deliberately does not
          reveal that the item exists or who is in the group. Identical to the
          answer a genuinely missing id gets.
        * **region** → `403 region_restricted` (or `region_unavailable` when the
          tenant has regions on and this caller resolves to none). Regions are a
          merchandising axis, not a secret, so the reason is named and a client
          can offer the switch.

        ### `payment_options`

        One entry per payment path the ITEM has a price for — a method the item
        cannot be bought with at all is omitted entirely, because there is no tab
        to render. A method the TENANT has switched off is present with
        `available: false` and a `block_reason`, because that is a state an admin
        can change and a client should be able to explain it.

        The availability rules are the ones the web checkout builds its payment
        options from, including the two that matter most: `points` requires the
        caller's balance to actually cover it (`block_reason: "Insufficient
        points"`), and `mixed` requires the card AND points toggles to both be
        on, because a split spends from both.

        Whether the user has finished choosing variants is client state, so it is
        reported as `has_variants` / `variants` rather than folded into
        `block_reason` — otherwise every freshly-opened detail screen would look
        broken.

        ### `size_chart`

        The size chart the web page shows under the options, as the store admin
        entered it: `{ headers: [String], rows: [[String]], note: String|null }`
        — `headers` names the columns, each row is one size (first cell the size)
        padded to the header width. `null` when the item has no chart.
      operationId: showCompanyStoreCatalogItem
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: StoreItem id.
      - name: region_id
        in: query
        required: false
        schema:
          type: integer
        description: The region to evaluate access in. A region-specific item is reachable
          only from its own region; global items are reachable from every region.
      responses:
        '200':
          description: The item, with the caller's context
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    allOf:
                    - "$ref": "#/components/schemas/CompanyStoreContext"
                    - type: object
                      properties:
                        item:
                          "$ref": "#/components/schemas/CompanyStoreItemDetail"
                  unread_notification_count:
                    type: integer
              example:
                catalog:
                  item:
                    id: 1
                    name: Company Logo Hoodie
                    description: Soft fleece pullover hoodie with an embroidered company
                      logo.
                    category: swag
                    category_label: Swag
                    category_icon: tshirt
                    category_color: primary
                    image_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                    large_image_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                    featured: true
                    status: active
                    status_label: Active
                    available: true
                    low_stock: false
                    inventory_count:
                    stock_label: In stock
                    stock_detail_label: In stock
                    requires_shipping: true
                    points_price: 4500
                    cash_price:
                    price_label: 4500 pts
                    redeemable_with_points: true
                    purchasable_with_cash: false
                    affordable: true
                    points_to_go: 0
                    progress_percent: 100
                    ready_to_redeem: true
                    has_variants: true
                    variant_types:
                    - size
                    - color
                    digital_delivery: false
                    personalizable: false
                    wishlisted: true
                    restock_watch: false
                    can_quick_redeem: false
                    category_enabled: true
                    requires_approval: false
                    photos:
                    - url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey/hoodie.jpg
                      alt: Company Logo Hoodie
                    - url: https://cdn.mangoapps.com/library/hoodie-back.jpg
                      alt: Company Logo Hoodie photo 2
                    variants:
                    - type: size
                      label: Size
                      required: true
                      options:
                      - S
                      - M
                      - L
                      - XL
                    - type: color
                      label: Color
                      required: false
                      options:
                      - Black
                      - Heather Gray
                    item_details:
                      category: swag
                      category_label: Swag
                      sku: SWAG-HOOD-01
                      provider_type: internal
                      provider_label: Internal
                      requires_shipping: true
                      estimated_delivery_days: 5
                      delivery_label: 5 business days
                      fair_market_value: 45.0
                    payment_options:
                    - key: points
                      label: Redeem with Points
                      points_required: 4500
                      cash_required:
                      available: true
                      block_reason:
                    - key: cash
                      label: Buy with Card
                      points_required:
                      cash_required: 45.0
                      available: false
                      block_reason: Card purchases aren't enabled for this store
                    related_items: []
                unread_notification_count: 3
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` / `access_denied` / `store_disabled`
            (see the listing endpoint), or `region_restricted` / `region_unavailable`
            for an item outside the caller's region."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
              examples:
                region_restricted:
                  value:
                    error:
                      code: region_restricted
                      message: This item is not available in your region.
        '404':
          description: No such item in this tenant — or an item restricted to a recipient
            group this caller is not in. Deliberately the same answer for both, so
            the response cannot disclose that a targeted item exists.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreError"
              examples:
                audience_restricted:
                  value:
                    error:
                      code: not_found
                      message: This item isn't available for redemption right now.
  "/company-store/catalog/{id}/checkout":
    get:
      tags:
      - Company Store
      summary: Price a checkout and disclose what will happen
      description: |
        Everything the **Checkout** screen renders before anything is committed —
        the native mirror of the web checkout GET.

        ### What it answers

        * **`payment_options`** — every path this item *could* take, each with
          `available` and, when it isn't, a `block_reason` written for the buyer.
          Built by the same method `GET /catalog/{id}` uses, so the item screen
          and the checkout screen cannot disagree. Two rules here are
          load-bearing and have both been bugs: `points` needs the wallet to
          actually cover it, and `mixed` needs **both** the cash and points
          toggles on, not just the mixed one.
        * **`totals`** — what each path costs at this `quantity`, priced through
          `::Store::CheckoutService`'s own public conversions rather than
          re-derived here.
        * **`shipping.collected_by`** — `app` or `stripe`. This is the one thing a
          native client cannot work out for itself: with Stripe Tax on, a
          shippable item's address is collected by **Stripe** so `automatic_tax`
          can compute destination tax on it, and your form must skip its address
          step or the buyer types it twice.
        * **`disclosures`** — whether this redemption will be **held for
          approval** (and by which tier), whether the velocity brake will hold
          it, and the monthly points cap with what is left of it. For a one-tap
          redeem this is the only place a hold can be disclosed at all.

        ### The mixed-payment slider

        `totals.mixed.max_points` is the slider's ceiling:
        `min(points needed to cover the whole price, the caller's balance)`.
        Spend past it and there is no card portion left, which the write detects
        and routes to the points path — so a slider allowed past the ceiling
        silently changes which payment type the buyer gets.

        Pass `points_to_use` to have the split quoted back:
        `totals.mixed.cash_after_points` is what the card will be charged.

        ### It does not refuse an unbuyable item

        An out-of-stock, inactive, or switched-off-category item still answers
        **200**, with `can_checkout: false` and a `block_reason` on every path.
        A 403 here would make "this category is switched off" indistinguishable
        from "you are not in this item's audience", and that distinction is
        exactly what must not be disclosed. Region and audience restrictions DO
        refuse (404/403), because those are disclosure boundaries rather than
        states.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The numeric store item id.
        schema:
          type: integer
      - name: quantity
        in: query
        required: false
        description: Prices every path at this quantity. Clamped to 1..99, and `quantity.max`
          reports the real ceiling for this item (its tracked inventory, when it tracks
          any).
        schema:
          type: integer
          default: 1
      - name: payment_type
        in: query
        required: false
        description: Which path the buyer picked on the item screen. Echoed back as
          `selected_payment` so their tap is not lost; ignored (and replaced with
          the first available path) when this item cannot take it.
        schema:
          type: string
          enum:
          - points
          - cash
          - mixed
      - name: points_to_use
        in: query
        required: false
        description: For `mixed` — quotes the split for this many points without committing
          anything.
        schema:
          type: integer
      responses:
        '200':
          description: The priced checkout. Nothing has been written.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutPreviewResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            * `insufficient_permissions` — the token lacks `read:company_store`
              (checked first, before the app gate). This preview is a GET; the
              POST below requires `write:company_store`.
            * `access_denied` — no Company Store access.
            * `store_disabled` — the tenant paused the store.
            * `region_restricted` — the item belongs to another region (or
              `region_unavailable` when the tenant has regions on and this caller
              resolves to none). Regions are a merchandising axis, not a secret,
              so the reason is named and a client can offer the switch.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: |
            `not_found` — no such item in this tenant, OR it is restricted to an
            audience group this caller is not in. Deliberately the same answer,
            with copy that does not reveal that the item exists or who is in the
            group. Same contract as GET /company-store/catalog/{id}.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
    post:
      tags:
      - Company Store
      summary: Place an order — points, cash, or mixed
      description: |
        **The write.** One endpoint, three payment modes, exactly as the web
        `#process_checkout` is one action with three branches.

        ### Points

        Placed in this request. `placed: true`, and the response carries the
        order and the wallet **after** the debit.

        `status` may be `pending_approval`: high-value redemptions and any order
        tripping the velocity brake are HELD, with the points deducted but no
        fulfilment until an approver releases it. **Do not celebrate a hold** —
        `requires_approval` and the `message` both say so, and the design's own
        flow branches on it.

        ### Cash and mixed

        The order is created **pending**: the points portion is debited and the
        stock is reserved *before* the buyer pays, because that is the only way
        to stop two people spending the same points or buying the last unit.
        `placed: false`, and `payment` carries the Stripe Checkout URL.

        Open `payment.checkout_url` in the system browser. Then either intercept
        `payment.return_url_prefix` / `payment.cancel_url_prefix` on the
        in-browser navigation, or simply poll `payment.complete_url` once the
        browser closes — they are the same call.

        The success and cancel URLs are the app's own, and are deliberately
        **not** client-supplied: a caller-controlled `success_url` is an open
        redirect that we would also be handing to Stripe, and the service
        resolves the item's product image against it (a custom app scheme there
        ships a session with no images).

        ### Every toggle is re-enforced here

        `payment_type` is user-controlled, so the GET's filtering is a courtesy,
        not the gate. `points_to_use` is additionally forced to **0** for a
        `cash` checkout — without that, a hidden mixed-payment slider leaking a
        value into a card-only submit silently spends points.

        ### Idempotency

        Send `idempotency_key`. A repeat returns the SAME order — and for a card
        checkout, the same still-open Stripe session — instead of a second
        pending order and a second points debit. If the retried session was
        already paid, the response reports the order alone with no `payment`
        block rather than handing back a dead URL; if it is still owed but can
        no longer be opened (expired, closed), the answer is
        `409 checkout_session_unavailable`. The answer follows the ORDER the key
        placed, never the `payment_type` of the retry: a `points` retry under
        the key of a card order still awaiting its payment is answered as the
        card checkout answers it (`placed: false` with `payment`, or the 409).

        A repeat answers `replayed: true` and writes nothing. When the order is
        placed, its `message` says it was already placed ("You already placed
        this order (Order …)") — show that, never a new-order celebration. When
        the order has been cancelled since (the buyer backed out of Stripe, or
        cancelled it; an admin cancelled it; its approver turned it down), the
        answer is `placed: true` (nothing is owed) with `replay_status`
        `cancelled` or `declined` and a `message` saying so and that a new
        checkout orders it again — its payment page is never reopened.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The numeric store item id.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/CompanyStoreCheckoutRequest"
      responses:
        '200':
          description: 'Either the order was PLACED (`placed: true`, points path)
            or a Stripe session is waiting to be paid (`placed: false`, with `payment`).
            Branch on `placed`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutWriteResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            * `insufficient_permissions` — the token lacks `write:company_store`
              (checked first, before the app gate).
            * `access_denied` — no Company Store access.
            * `store_disabled` — the tenant paused the store.
            * `region_restricted` / `region_unavailable` — the item belongs to
              another region, or the tenant has regions on and this caller
              resolves to none.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: |
            `not_found` — no such item in this tenant, OR it is restricted to an
            audience group this caller is not in. Deliberately the same answer,
            so a deep link cannot confirm that a targeted item exists.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '409':
          description: |
            `checkout_session_unavailable` — an idempotent RETRY (same
            `idempotency_key`) whose order is still awaiting payment but whose
            Stripe session can no longer be reopened: it expired, it was closed,
            or Stripe could not be reached. Nothing was written and nothing was
            paid; the points are still held and the stock still reserved.

            `error.details` carries `order`, `wallet`, and both closing
            endpoints — `complete_url` in case the session WAS paid before it
            closed, `abandon_url` to hand the points and the stock back now
            rather than waiting for the 25h sweep.

            This is NOT reported as a success: `placed: true` means "nothing
            further is owed", and an unpaid pending order does not qualify.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '422':
          description: |
            Nothing was written. Every code here is a refusal of this request
            given current state, which is what a client retries with different
            input.

            * `invalid_payment_type` — not one of points / cash / mixed.
            * `points_disabled` — the tenant switched redemption off, or this
              item carries no points price. Answers a `mixed` submit carrying
              `points_to_use > 0` as well as a `points` one: a split payment
              spends points, so it needs the points toggle too — which is why
              `payment_options[mixed].available` requires all three.
            * `cash_disabled` — the tenant switched card payments off.
            * `mixed_disabled` — the tenant switched split payments off.
            * `item_unavailable` — the item's category is switched off. Note the
              GET still renders such an item; only the write refuses.
            * `variants_required` — the item has options and the selection is
              incomplete.
            * `checkout_failed` — the service refused, and `message` is its own
              prose written for the buyer: not enough points (with the numbers),
              the team's store budget is exhausted, it just sold out, the
              monthly cap would be crossed, engraving details are missing, or
              Stripe rejected the session. Deliberately one code — the service
              does not return machine-readable reasons, and matching on its
              strings here would break the moment the copy is edited. Show
              `message` to the buyer.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
  "/company-store/checkout/{order_number}/complete":
    post:
      tags:
      - Company Store
      summary: Confirm the card leg of a checkout (idempotent — poll this)
      description: |
        Closes the round trip: verifies with Stripe that the session was paid,
        captures the payment intent, flips the order to `processing`, copies back
        any address Stripe collected, fulfils digital items inline, enqueues
        provider fulfilment, and sends the buyer's confirmation **once**.

        ### This is a polling endpoint

        It is idempotent by design, because a native return trip may never
        arrive. All of these are the same call:

        * the client intercepted the success URL — confirm now;
        * the browser closed and the client doesn't know what happened — ask;
        * the app is reconciling on next launch, an hour later — ask again.

        An order already `processing`/`fulfilled` answers **200** with the order
        and does **not** re-send the confirmation email or the admin alert (the
        service's own first-capture guard). That 200 holds even when Stripe
        cannot be re-reached to re-verify a capture that already happened —
        `processing` is only ever written by the capture itself, so the status is
        the proof, and a polling client must not be handed `checkout_closed`
        (whose contract is "terminal, stop polling") for a paid order that is
        being prepared.

        ### Why a webhook is not doing this

        There is no `checkout.session.completed` handling for store sessions —
        see the file header. This endpoint and the 6-hourly
        `CompanyStore::StaleCheckoutSweepJob` are the only two paths that will
        ever confirm a store payment, which is why a client should poll rather
        than assume the redirect landed.

        ### Own checkouts only

        Completing a payment is the buyer closing their own round trip; there is
        no surface anywhere, web included, where one person finishes another's.
        Somebody else's order is **404**, not 403 — whether that order number
        exists is not this caller's business.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/CompanyStoreCheckoutOrderNumber"
      responses:
        '200':
          description: 'Paid and confirmed — or already was. `placed: true` and the
            order is `processing` or beyond.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutCompleteResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`)
            / `access_denied` / `store_disabled`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: No such order number among this caller's own orders in this
            tenant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '409':
          description: |
            Nothing changed. `error.details.order` carries the order's current
            state so a polling client can decide what to do without a second
            request.

            * `payment_incomplete` — the order is still `pending` and Stripe says
              the session is not paid. The buyer hasn't finished. **Keep
              polling** (or stop and let the sweep reconcile).
            * `checkout_closed` — the order is terminal (cancelled, refunded, or
              swept). **Stop polling and refresh.** The service refuses to
              resurrect a terminal order, and if that session was in fact paid in
              the race window it refunds the buyer rather than re-fulfilling.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '422':
          description: "`no_checkout_session` — this order was not paid by card (a
            points-only redemption), so there is no payment to confirm."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
  "/company-store/checkout/{order_number}/abandon":
    post:
      tags:
      - Company Store
      summary: Abandon an unpaid checkout and hand the points back now
      description: |
        The native equivalent of the web's Stripe cancel landing. Cancels the
        pending order and immediately restores the points, the team budget
        draw-down and the reserved stock, then expires the Stripe session so it
        can no longer be paid.

        **Why this endpoint has to exist:** a cash/mixed order is created before
        payment, so a buyer who backs out of the browser leaves points debited
        and stock reserved. The web flow learns about it from Stripe's own cancel
        link. A native client has no such link — it has to say so, and without
        this call those points stay held until
        `CompanyStore::StaleCheckoutSweepJob` runs, up to ~31 hours later.

        Call it when the buyer dismisses the payment browser without paying.

        Only a **pending** order can be abandoned. A paid one needs a
        cancellation (which carries a card refund) — that is
        `POST /company-store/orders/{order_number}/cancel`, and the 409 here
        points at it.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/CompanyStoreCheckoutOrderNumber"
      responses:
        '200':
          description: Cancelled. Points, budget and stock restored.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutAbandonResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`)
            / `access_denied` / `store_disabled`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '404':
          description: No such order number among this caller's own orders in this
            tenant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '409':
          description: |
            * `not_abandonable` — the order is no longer pending. If it was paid,
              cancel it instead; `error.details.cancel_url` is the endpoint to
              use.
            * `checkout_closed` — it moved out from under the request (paid or
              swept in that instant). Refresh the order.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
        '422':
          description: "`no_checkout_session` — a points-only order has no checkout
            to abandon. Cancel it instead."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCheckoutError"
  "/company-store/shipping-countries":
    get:
      tags:
      - Company Store
      summary: Shipping countries and their states/provinces
      description: |
        Everything needed to render the checkout's country and state dropdowns:
        the shippable countries, each country's states nested underneath it, and
        the values the checkout write accepts for both fields.

        Takes no parameters.

        ### Read this before you submit an address

        Render `label`; submit `value`. The two fields' value shapes deliberately
        differ — country submits `"United States"` (the display name) while state
        submits `"CA"` (the 2-letter code). Sending an ISO country code or a
        spelled-out state name is accepted by the checkout and then fails at
        fulfillment, after the points are spent. See this file's header.

        ### Why states are nested rather than a separate lookup

        Nested under their country, so there is no correlation key to get wrong
        and no second request to make. The whole payload is ~52 static rows. The
        cascading per-country fetch pattern used elsewhere in the product
        (`/api/jurisdictions`) exists for the tenant-seeded jurisdiction tree,
        which is thousands of cities deep; this is not that.

        ### When a country has no state list

        `states` is `[]` and `prefill.states_required` is `false`. That pair means
        **render a free-text field**, not "still loading" — a client that showed
        an empty dropdown would leave the buyer unable to complete the address.
        Today every offered country has a list, so this is forward-compatibility,
        not a live case.

        ### `prefill`

        What the web form pre-fills from the caller's own profile, resolved the
        same way. `state` is normalised to a code because a stored profile state
        is not one shape in practice — an imported address may hold
        `"California"`, `"CA"` or `"ca"`. A profile state that resolves to no
        known code comes back **null** rather than a guess: that is exactly the
        case where the buyer must choose, and pre-selecting a wrong state is
        worse than pre-selecting none.

        Never `403`/`404` for a caller past the gates, and never empty — a
        successful response always carries at least one country.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The shippable countries, their states, and the caller's prefill.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreShippingCountriesResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token holds neither `read:company_store`
            nor `read:own_company_store` — checked first, before the app gate), `access_denied`
            (the app isn't accessible to this caller) or `store_disabled` (an admin
            paused the store)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreShippingCountriesError"
  "/company-store/orders":
    get:
      tags:
      - Company Store
      summary: Order history with per-status counts
      description: |
        Everything the **Orders** tab renders in one request: the paginated order
        list, the filter pill row with a **count per status**, the caller's
        role/tenant context, and the page envelope.

        ### Whose orders

        `scope` decides the pool, and it is never widened implicitly:

        * **`mine` (default)** — the caller's own orders. This is what an
          employee, a manager and an admin all get when they open the tab, so an
          admin's Orders screen is their own order history exactly like everyone
          else's.
        * **`all`** — every order in the tenant, the read-side of the web admin
          orders queue. Each row additionally carries `employee`. Available only
          to a **store admin** (a business admin/owner or a Company Store
          app-admin); anyone else gets **403 `forbidden`** rather than a silently
          narrowed list, which would answer a different question than the one
          asked. `can_view_all_orders` tells a client whether to offer the toggle
          at all.

        The mobile design has two personas — Employee and Manager
        ("everything above, plus approves orders") — and the Orders screen is
        **identical for both**. A manager's extra is the separate redemption
        approval queue, not a wider order list, so nothing on this endpoint keys
        off manager status.

        ### Counts and the pill row

        `counts` carries **every** `StoreOrder` status plus `all`, always present
        (0 when empty), from ONE grouped query. `status_filters` is that same
        data arranged as the design's pill row — value, label, count, whether it
        is selected, and whether the web chip would show it.

        Two rules matter and are easy to get wrong client-side:

        * Counts honour **`search`** but NOT **`status`**. Each pill has to report
          how many rows tapping it would land on, so narrowing the counts by the
          currently-selected status would make every other pill read 0. A pill
          that ignored the search term instead would over-count — advertising
          "Fulfilled 12" onto a list of 2.
        * `visible` is false for `pending_approval` and `refunded` while their
          count is 0 and they are not selected — these are rare states and the web
          chips hide them rather than showing a permanent "Refunded 0". The other
          five are always visible. A client can ignore `visible` and render all
          seven; it exists so the pill row can match the web without hardcoding
          the vocabulary.

        `pending_approval` is labelled **"Awaiting Approval"**, not a titleized
        `Pending Approval`, because it would otherwise collide with the separate
        `pending` status on the same screen. Every store surface says the same.

        Note that **`pending` and `pending_approval` are counted separately and
        both appear in the list**, matching the shipped web page rather than the
        mobile design's prototype (which folded held orders into the Pending pill
        while hiding them from the list — so a held order was counted but
        unreachable). A held redemption has the employee's points locked up; it is
        the order they are most likely to be looking for, so it is listed, given
        its own pill, and carries an `approval` block on the detail payload. A
        client that wants the prototype's grouping can add the two counts.

        ### Gotchas

        * An unrecognised `status` is **ignored** (the response falls back to
          All) rather than returning nothing — `filters.status` reports what was
          actually applied, so a client can tell the difference.
        * `per_page` is clamped server-side to 50; `filters.per_page` and
          `meta.per_page` report the value in force.
        * `has_fulfillment_error` is true only while the order is still `pending`.
          A manually resolved order stops advertising the failure that preceded
          it, so a client must not treat a resolved order as failed.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        description: Narrow to one status. Blank/absent = All. An unrecognised value
          is IGNORED (falls back to All) rather than returning an empty list.
        schema:
          type: string
          enum:
          - pending_approval
          - pending
          - processing
          - fulfilled
          - cancelled
          - refunded
      - name: search
        in: query
        required: false
        description: Case-insensitive match on the order number or the ordered item's
          name — the two things a user remembers about an old order. In the `all`
          scope it additionally matches the employee's name and email. Narrows the
          list AND the counts together.
        schema:
          type: string
      - name: scope
        in: query
        required: false
        description: "`mine` (default) or `all`. `all` is the whole tenant's orders
          and requires a store admin — 403 `forbidden` otherwise. Any other value
          is treated as `mine`."
        schema:
          type: string
          enum:
          - mine
          - all
          default: mine
      - name: page
        in: query
        required: false
        description: 1-based page number. Values below 1 are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Clamped to 50; a non-positive or junk value falls
          back to 20.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The caller's order history, its filter bar and its counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: object
                    required:
                    - viewer
                    - features
                    - scope
                    - can_view_all_orders
                    - filters
                    - counts
                    - status_filters
                    - items
                    - meta
                    properties:
                      viewer:
                        "$ref": "#/components/schemas/CompanyStoreOrdersViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStoreOrdersFeatures"
                      scope:
                        type: string
                        enum:
                        - mine
                        - all
                        description: The pool this page was actually drawn from.
                      can_view_all_orders:
                        type: boolean
                        description: Whether this caller may request `scope=all`.
                          Render the scope toggle from this rather than from a role
                          guess, so a client never offers a switch whose request would
                          403.
                      filters:
                        type: object
                        description: The filter values the server actually APPLIED
                          (not what was sent).
                        properties:
                          status:
                            type: string
                            nullable: true
                            description: null when no status filter is in force (including
                              when an unknown one was dropped).
                          search:
                            type: string
                            nullable: true
                          per_page:
                            type: integer
                            description: The clamped page size in force.
                      counts:
                        "$ref": "#/components/schemas/CompanyStoreOrderStatusCounts"
                      status_filters:
                        type: array
                        description: The design's pill row, in its display order —
                          All, Awaiting Approval, Pending, Processing, Fulfilled,
                          Cancelled / not approved (cancelled orders and the redemptions
                          an approver turned down), Refunded. The numbers are the
                          same ones `counts` reports.
                        items:
                          "$ref": "#/components/schemas/CompanyStoreOrderStatusFilter"
                      items:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStoreOrderListRow"
                      meta:
                        "$ref": "#/components/schemas/CompanyStoreOrdersPageMeta"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            One of four refusals, distinguished by `error.code`:

            * `insufficient_permissions` — the token lacks `read:company_store`.
              Checked first, before any of the three below.
            * `access_denied` — the Company Store app isn't enabled for this
              tenant, or this user is outside the app's audience.
            * `forbidden` — `scope=all` was requested by someone who isn't a
              store admin.

            A paused store (`store_enabled: false`) still serves the caller's
            own orders here — their gift-card codes and tracking are on them —
            so `store_disabled` is never returned by this endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                        - store_disabled
                        - forbidden
                      message:
                        type: string
  "/company-store/orders/{order_number}":
    get:
      tags:
      - Company Store
      summary: One order in full — timeline, reward payload and available actions
      description: |
        The **Order Detail** screen. Built on the same shared row card the list
        uses, so the two can never disagree about status, money or the item, plus
        everything the detail screen adds.

        ### The timeline

        `timeline` comes from `Store::OrderTimeline`, shared **verbatim** with the
        web page's own Order Timeline card. It is **derived** from the order's
        status, its lifecycle timestamps and its item's delivery shape — which is
        why it can name what is still to come ("On its way · Est. 5 business days
        after it ships"), the thing an employee actually opens this screen for.
        That is deliberately different from the append-only audit trail the web
        *admin* order page renders.

        Steps are ordered and each carries a `state`:

        | state | meaning |
        |---|---|
        | `done` | already happened |
        | `current` | the step the order is standing on |
        | `upcoming` | not reached yet — still named, so the user can see what's next |
        | `cancelled` | terminal: cancelled |
        | `refunded` | terminal: refunded |

        A live order runs `placed → [awaiting_approval] → processing → delivered`;
        `processing` is `current` only while the order's status is `processing`.
        A `pending` order (placed, nobody has started on it) has no `current`
        step: `processing` is `upcoming`, and its `detail` says what it waits for.
        A cancelled or refunded one stops at `placed → cancelled|refunded` and
        offers nothing forward-looking, because there is nowhere left to go.

        ### What each role gets

        * **The person who placed it** — everything below, including the
          `fulfillment` reward payload (tracking number and link, gift card code,
          redemption link, delivery email, donation receipt) and the shipping
          address. Exactly what the web order page shows the same person.
        * **A store admin** — may open **anyone's** order (`is_mine: false`), and
          additionally receives the `admin` block: provider order id, provider
          status, funding source and the raw connector `fulfillment_error`. The
          web shows these on the admin order page only; an employee gets
          reassurance copy, never a connector error string.
        * **Anyone else** — 403 `forbidden` on someone else's order.

        ### Actions are affordances, not permissions to guess at

        `actions` is resolved server-side against the very predicates the write
        paths enforce — including this API's own
        `POST :order_number/cancel` — so a control rendered from it is one whose
        write would be accepted, and one the server accepts is never hidden.
        `cancel_blocked_reason` explains a disabled cancel button while the order
        still *looks* cancellable (already dispatched to the reward provider, for
        instance) instead of letting the tap bounce.

        `can_cancel` is offered to **both** people the web offers it to: the buyer
        (self-service) and a store admin on anyone's order, with `cancel_as`
        naming which. The other three — return, report a problem, reorder — are
        the **buyer's own** acts and are `false` for an admin however wide their
        read access, exactly as on the web.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        description: The ORDER NUMBER (e.g. `ORD-20260819-ABC123`), the same identifier
          the web URLs use — not the numeric id.
        schema:
          type: string
      responses:
        '200':
          description: The order, its timeline, its reward payload and the viewer's
            available actions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    "$ref": "#/components/schemas/CompanyStoreOrderDetail"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            `insufficient_permissions` (the token lacks `read:company_store`,
            checked first), `access_denied` (no Company Store access), or
            `forbidden` — the
            order belongs to someone else and the caller is not a store admin.
            Deliberately distinct from 404: the order exists in this tenant, the
            caller just may not read it.
        '404':
          description: No order with that number in this tenant.
  "/company-store/orders/{order_number}/cancel":
    post:
      tags:
      - Company Store
      summary: Cancel an order
      description: |
        Cancels the order and reverses everything the purchase did — the native
        twin of **both** web cancel buttons.

        ### Who may cancel

        Resolved server-side by `StoreOrder#cancel_actor_for`, the same rule the
        detail payload's `actions.can_cancel` is built from, so a control rendered
        from that payload is one this endpoint accepts:

        | `actor` | Who | Reach |
        |---|---|---|
        | `owner` | the employee who placed the order | while nothing irreversible has happened — not fulfilled, and not yet dispatched to the reward provider. Mirrors the web order page's own **Cancel order** button. |
        | `admin` | a **store admin** (business admin/owner, or a Company Store app-admin) on anyone's order | the broader power, reaching an order already at the provider. Mirrors the web **admin** order page's Cancel. Cash orders additionally require a business administrator — see below. |

        `owner` wins when a store admin cancels their own cancellable order: the
        narrower claim, and the one the employee-facing note records.

        Everything else the buyer can do (return, report a problem, reorder) stays
        the buyer's alone — an admin is offered none of them on somebody else's
        order.

        ### Cash orders move real money

        Cancelling a cash or mixed order enqueues a **live Stripe refund**
        (`CompanyStore::PaymentRefundJob`), so it requires a **business
        administrator** — a Company Store app-admin is not enough and gets 403
        `cash_reversal_forbidden`, a distinct code because nothing is wrong with
        the request: a different person has to take the action. The buyer's own
        self-service cancel is unaffected — that is the same money going back to
        the same person.

        ### What the cancel does

        The work is `StoreOrder#cancel!` — one row-locked transaction, unchanged
        and unwrapped, exactly as both web buttons invoke it:

        * refunds the points to the employee's wallet, and to the team store
          budget the redemption was drawn from, if any
        * restocks the item's inventory by the order quantity
        * reverses a captured card charge (async — `card_refund_pending` says when
          one is on its way) and expires a still-open Stripe Checkout Session, so
          a cancelled order can't be paid for afterwards
        * clears the stale in-flight fulfilment flags, so a cancelled order stops
          reporting the failure that preceded it
        * notifies the employee (email + in-app) off the status change

        No second notification and no second write are added by this endpoint.

        ### The response

        Carries the **full re-rendered order detail** alongside the cancellation
        receipt, so the client updates the screen it just acted on from this one
        response instead of following it with a `GET`. The receipt's
        `points_refunded` / `card_refund_pending` are the **pre-write** readings —
        after the cancel the points are already back and the refund already
        enqueued, so asking the row afterwards would report `0` / `false` and the
        confirmation would quietly stop naming the money.

        Idempotency: a second cancel of the same order answers 409
        `not_cancellable`. Nothing is refunded twice.

        Delete-level (owner decision 2026-10-04): requires the `destructive:company_store`
        scope. A personal token cannot hold it: do this in MangoApps instead.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        description: The ORDER NUMBER (e.g. `ORD-20260819-ABC123`), the same identifier
          the web URLs and the detail endpoint use — not the numeric id.
        schema:
          type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why it was cancelled. Optional; whitespace-only is
                    treated as none given. On a store ADMIN's cancel it is the reason
                    the employee reads on their order page and in the cancellation
                    email, exactly as on the web admin page; on the employee's own
                    cancel it is an audit note only.
                  example: Ordered the wrong size
      responses:
        '200':
          description: Cancelled. Points refunded, inventory restocked, employee notified.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            * `insufficient_permissions` — the token lacks `write:company_store`
              (checked first, before any of the below).
            * `access_denied` — no Company Store access.
            * `store_disabled` — the tenant paused the store (a store admin
              cancelling someone else's order on a wide token still may).
            * `forbidden` — the order is somebody else's and the caller is not a
              store admin. Retrying will never help; a client should stop
              offering the control.
            * `cash_reversal_forbidden` — a store admin who is not a **business**
              administrator, on a cash/mixed order. The order IS cancellable; a
              different person has to do it.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelError"
        '404':
          description: No order with that number in this tenant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelError"
        '409':
          description: |
            * `not_cancellable` — it IS this caller's to cancel, but the order has
              moved on: already fulfilled, already cancelled or refunded, or (for
              the buyer's own self-service cancel) already dispatched to the
              reward provider. The caller's screen is stale — refresh it. This is
              deliberately **not** a 403: it is a state answer, not a permissions
              one, and a client that hid the control here would hide it for orders
              that are still cancellable.
            * `cancel_failed` — the order moved out from under the request between
              the permission check and the write (cancelled or fulfilled by
              someone else in that window). Nothing was changed; refresh and
              re-read the order.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreOrderCancelError"
  "/company-store/orders/{order_number}/request-return":
    post:
      tags:
      - Company Store
      summary: Request a return or exchange, or report a problem with a digital order
      description: |
        The buyer files a return / exchange on their OWN shipped order, or
        reports a problem with a fulfilled digital order (a gift card code that
        never arrived or doesn't work) — the native twin of the web and phone
        order pages' forms, on the same rule (`StoreOrder#return_request_kind`).
        The request goes to the store admins to decide. Offered as the order
        detail's `actions.return_url` while `actions.can_return` or
        `actions.can_report_problem` is true. Refused while the store is paused.
        Asks `write:company_store`.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        schema:
          type: string
        description: The ORDER NUMBER (`ORD-…`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
                  description: One of the detail's `actions.return_reasons` values
                    — a return's physical reasons (wrong_size, defective, not_as_described,
                    changed_mind, other), a problem report's digital ones (code_not_received,
                    code_invalid, wrong_amount, other).
                return_type:
                  type: string
                  enum:
                  - refund
                  - exchange
                  default: refund
                  description: Ignored for a problem report, which is always a refund.
                notes:
                  type: string
                  description: Optional — anything the store admin should know.
                message:
                  type: string
                  description: Accepted in place of `notes`.
                requested_variants:
                  type: object
                  additionalProperties:
                    type: string
                  description: 'An exchange''s wanted variant (e.g. `{ "Size": "XL"
                    }`); keys the item does not define are dropped.'
      responses:
        '200':
          description: Filed; the full order detail (its `return_request` now `requested`,
            with `withdraw_url`), the new request, and a message for the buyer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    type: object
                    description: The order detail, as GET /company-store/orders/{order_number}
                      renders it.
                  return_request:
                    type: object
                    description: The order detail's `return_request` block for the
                      request just filed.
                  message:
                    type: string
        '403':
          description: "`forbidden` — not the caller's own order; `store_disabled`
            — the store is paused."
        '404':
          description: No such order in this workspace.
        '409':
          description: "`not_returnable` — the order can't be returned or reported
            now (not fulfilled, not shipped, or a request is already open)."
        '422':
          description: "`invalid_reason` — the reason isn't one this order's form
            takes (`details.valid_reasons` lists them); `validation_error` — the request
            could not be saved."
  "/company-store/orders/{order_number}/withdraw-return":
    post:
      tags:
      - Company Store
      summary: Withdraw your open return or problem report
      description: |
        The buyer takes back their open return / exchange request or gift-card
        problem report on their OWN order — the native twin of the order page's
        Withdraw button, on the same `Store::ReturnService.withdraw!`. It closes
        as `withdrawn`, leaves every store admin's "Needs you" list, and frees
        the order for a new return or report. Offered as the order detail's
        `return_request.withdraw_url`. Allowed while the store is paused.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        schema:
          type: string
        description: The ORDER NUMBER (`ORD-…`).
      responses:
        '200':
          description: Withdrawn; the re-rendered order card and a message for the
            buyer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    type: object
                    description: The order card
                    as GET /orders renders a row.:
                  message:
                    type: string
        '403':
          description: "`forbidden` — not the caller's own order."
        '404':
          description: No such order in this workspace.
        '409':
          description: "`no_open_return` — nothing open to withdraw on this order."
  "/company-store/cart":
    get:
      tags:
      - Company Store
      summary: The caller's cart
      description: |
        Every line re-priced from the live item (prices are never stored),
        each with an `issue` when it can't check out as it stands (out of
        stock, region, variant no longer offered …), the totals, the caps a
        stepper needs, the cart's fulfilment route and whether it ships, and
        `blockers` — the reasons a points checkout would be refused, in the
        words the web cart renders beside its disabled Checkout button.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The cart (empty carts answer 200 with no lines).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: "`insufficient_permissions`, `access_denied` or `store_disabled`."
    delete:
      tags:
      - Company Store
      summary: Empty the cart
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The now-empty cart.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
  "/company-store/cart/lines":
    post:
      tags:
      - Company Store
      summary: Add an item to the cart
      description: |
        Adds `quantity` of `item_id` with the given `variants`. The same item
        with the same selection MERGES into its existing line (quantity is
        clamped to 5 and to stock); a different selection is a new line. A
        sixth distinct line is refused `cart_full`.

        Refusal codes (all 422, message written for the buyer): `unavailable`,
        `ineligible` (gift card / donation / engraved / cash-only / switched-off
        category), `route_mismatch`, `currency_mismatch`, `cart_full`,
        `out_of_stock`, `variant_invalid`, `region`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_id
              properties:
                item_id:
                  type: integer
                quantity:
                  type: integer
                  default: 1
                  minimum: 1
                  maximum: 5
                variants:
                  type: object
                  additionalProperties:
                    type: string
                  description: 'The picked options, keyed by variant type (`{ "size":
                    "L", "color": "Navy" }`). Required options must be present.'
      responses:
        '201':
          description: Added (or merged). `added_line_id` names the line; `warnings`
            lists anything clamped.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '422':
          description: Refused — see the codes above.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartError"
  "/company-store/cart/lines/{id}":
    patch:
      tags:
      - Company Store
      summary: Set a line's quantity
      description: "`quantity` 1–5 (clamped to stock); 0 or less removes the line."
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The cart line id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - quantity
              properties:
                quantity:
                  type: integer
      responses:
        '200':
          description: The cart after the change.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '404':
          description: "`line_not_found` — not a line of this caller's cart."
        '422':
          description: Refused (e.g. `out_of_stock`).
    delete:
      tags:
      - Company Store
      summary: Remove a line
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The cart after the removal.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartResponse"
        '404':
          description: "`line_not_found`."
  "/company-store/cart/checkout":
    get:
      tags:
      - Company Store
      summary: Preview the cart checkout
      description: |
        Everything the checkout screen renders and nothing re-derived here
        that the write derives differently: the payment paths this order may
        take (`points`, `cash`, `mixed`, each with `available` and a
        `block_reason` when the tenant switched it off, the wallet falls
        short, or the cart passes what is left of the monthly redemption cap
        or the team store budget — both of which the points checkout refuses),
        the one preselected (`points`, or `mixed` when points fall short and a
        split is offered), the split slider's ceiling (`amount.points_max`:
        the smallest of the balance, the points that cover the card total, and
        what is left under the monthly cap and the team budget), the shipping
        prefill and whether Stripe collects the address for a given payment
        type (`shipping.collected_on_stripe_for`), and the hold / budget
        disclosures on the points total.

        A cart with a blocked line answers 422 `cart_blocked` naming the lines;
        an empty cart 422 `cart_empty`.
      security:
      - BearerAuth: []
      parameters:
      - name: payment_type
        in: query
        required: false
        schema:
          type: string
          enum:
          - points
          - cash
          - mixed
        description: Preselect a path (ignored when it isn't available).
      responses:
        '200':
          description: The preview.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartCheckoutPreview"
        '422':
          description: "`cart_empty` or `cart_blocked` (details.issues names the lines)."
    post:
      tags:
      - Company Store
      summary: Place the cart as one order
      description: |
        ONE `StoreOrder` with one line per cart line, priced per selection,
        debiting the points total once. `points` places it in this request
        (`placed: true`; `held_for_approval` when a hold applies). `cash` and
        `mixed` create the order PENDING and answer `placed: false` with a
        Stripe Checkout URL — open it in the system browser, then poll
        POST /checkout/{order_number}/complete; POST /checkout/{order_number}/abandon
        cancels it AND restores the lines to the cart. A `mixed` checkout whose
        points cover the whole price is routed to the points path — branch on
        `placed`, never on what you asked for.

        The cart is emptied on success. Every rule the single-item checkout
        applies (caps, team budgets, approval tiers, velocity brake, inventory
        locks, idempotency) applies to the cart total.

        A repeat of an `idempotency_key` that already placed an order answers
        with THAT order before any cart check, writes nothing and leaves the
        cart as it is (`replayed: true`): its still-open Stripe page
        (`placed: false` + `payment`), `409 checkout_session_unavailable` when
        the card leg is still owed but its page can no longer be opened, or
        `placed: true` with `replay_status` and a `message` saying it was
        already placed — or, when that order has been cancelled since (an
        abandoned card payment, the buyer, an admin) or its approver turned it
        down, that it was, and that a new checkout orders it again. Nothing is
        owed on a cancelled order, so `placed` stays true for it: read
        `replay_status`, never `placed`, to tell the two apart.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - payment_type
              properties:
                payment_type:
                  type: string
                  enum:
                  - points
                  - cash
                  - mixed
                points_to_use:
                  type: integer
                  description: "`mixed` only — the points portion, up to `amount.points_max`."
                shipping_address:
                  type: object
                  description: Required when `shipping.required` and Stripe does not
                    collect it for this payment type.
                  properties:
                    name:
                      type: string
                    street1:
                      type: string
                    street2:
                      type: string
                    city:
                      type: string
                    state:
                      type: string
                      description: 2-letter code
                    zip:
                      type: string
                    country:
                      type: string
                    phone:
                      type: string
                idempotency_key:
                  type: string
                  format: uuid
                  description: Per-submission token; a retry with the same key returns
                    the same order instead of placing a second.
      responses:
        '200':
          description: 'Placed (`placed: true`) or awaiting card payment (`placed:
            false` + `payment`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreCartCheckoutResponse"
        '409':
          description: "`checkout_session_unavailable` — a repeat of an `idempotency_key`
            whose order still awaits its card payment, but whose Stripe session can
            no longer be opened. Nothing was written. `error.details` carries `order`,
            `wallet`, `complete_url` (in case it was paid) and `abandon_url` (hands
            the points and stock back and restores the lines to the cart) — the single-item
            checkout's 409."
        '422':
          description: "`cart_empty`, `payment_unavailable`, `shipping_incomplete`,
            or `checkout_failed` with the service's own sentence."
  "/company-store/orders/{order_number}/reorder":
    post:
      tags:
      - Company Store
      summary: Order Again — every line of a finished order back into the cart
      description: |
        The caller's OWN fulfilled, cancelled or refunded order, refilled into the cart
        through the one cart brain, so every add rule applies: a line that can
        no longer be added (discontinued, out of stock, switched-off category,
        wrong route for what the cart already holds) is named in `skipped`,
        never dropped silently. The order detail's `actions.reorder_url`
        points here when `reorder_via` is `cart`; a lone gift card or donation
        (`reorder_via: checkout`) still reorders through its single-item
        checkout deep link.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        schema:
          type: string
        description: The ORDER NUMBER (`ORD-…`).
      responses:
        '200':
          description: Lines added; the cart summary to render from.
          content:
            application/json:
              schema:
                type: object
                properties:
                  added:
                    type: integer
                  skipped:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        reason:
                          type: string
                  cart:
                    type: object
                    properties:
                      line_count:
                        type: integer
                      units:
                        type: integer
                      points:
                        type: integer
                  cart_url:
                    type: string
                  message:
                    type: string
        '403':
          description: "`forbidden` — not the caller's own order."
        '409':
          description: "`not_reorderable` — the order is still in progress."
        '422':
          description: "`nothing_added` — none of the lines can be ordered right now
            (the reasons are in the message)."
  "/company-store/orders/{order_number}/requests":
    post:
      tags:
      - Company Store
      summary: Message the store admins about an order
      description: |
        Opens a `StoreOrderRequest` on the caller's OWN order — the native
        twin of the order page's request form. `kind` must be one of the
        kinds the order currently accepts from its owner, which the detail
        payload names in `actions.request_kinds` (`question` on any live
        order; `cancellation` once the order is with the provider and can no
        longer be self-cancelled — `actions.can_request_cancellation`;
        `delivery_status` while the order is in progress; `address_change`
        before dispatch on a shipped order; `change_selection` before dispatch
        on an item with options; `code_issue` once a gift card is issued;
        `payment` and `other` on any live order; `damaged` once a
        physical order is fulfilled). A kind the order doesn't accept is
        refused 422 with the model's own sentence; one open cancellation
        request per order.

        The store admins are told (Inbox + email). A cancellation request
        lands in their queue; approving it recalls the order at the provider,
        cancels it and refunds the points, and the order's own cancellation
        notification tells the employee. Declines and answers reach the
        employee in their Inbox and by email, and appear on the order detail's
        `requests[]` with `resolution_notes`.
      security:
      - BearerAuth: []
      parameters:
      - name: order_number
        in: path
        required: true
        schema:
          type: string
        description: The ORDER NUMBER (`ORD-…`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - kind
              - message
              properties:
                kind:
                  type: string
                  enum:
                  - question
                  - cancellation
                  - delivery_status
                  - address_change
                  - change_selection
                  - damaged
                  - code_issue
                  - payment
                  - other
                message:
                  type: string
                  maxLength: 2000
      responses:
        '201':
          description: Opened. Carries the request, the re-rendered order card and
            a message for the buyer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
                  order:
                    type: object
                    description: The order card
                    as GET /orders renders a row.:
                  message:
                    type: string
        '404':
          description: Not one of the caller's orders (whether it exists is not this
            caller's business).
        '422':
          description: "`request_invalid` — the kind isn't available for this order,
            the message is blank, or a cancellation is already requested."
  "/company-store/order-requests/{id}/withdraw":
    post:
      tags:
      - Company Store
      summary: Withdraw your own open request
      description: |
        The sender takes back a request they sent about their order (asked by
        mistake, or no longer needed) while it is still open — the native twin
        of the order page's Withdraw button, on the same
        `Store::OrderRequestService.withdraw!`. It closes as `withdrawn`, leaves
        every store admin's "Needs you" list, and a withdrawn cancellation
        request lets the employee ask again later. Offered as the request's
        `withdraw_url`. Allowed while the store is paused (it spends nothing).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Withdrawn. Carries the request, the re-rendered order card
            and a message for the buyer.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
                  order:
                    type: object
                    description: The order card
                    as GET /orders renders a row.:
                  message:
                    type: string
        '404':
          description: Not one of the caller's own requests.
        '409':
          description: "`resolved` — the store admin already decided or answered it."
  "/company-store/admin/order-requests/{id}/approve":
    post:
      tags:
      - Company Store
      summary: 'Admin: approve a cancellation request'
      description: |
        Cancellation requests only. Recalls the order at its provider FIRST
        (`::Store::ProviderCancellation` — the admin cancel button's own step),
        then cancels it locally: points refunded, every line restocked, the
        card charge reversed, the employee notified off the status change. The
        provider's outcome is reported, never swallowed: `provider_cancellation`
        is `{ success: true }`, `{ success: false, error }` when the provider
        refused (the order is still cancelled here — the admin decided knowing
        it may already be in production), or `{ unconfirmed: true, error }` when
        we cannot tell what the provider did. Cash orders require a **business**
        administrator (403 `forbidden`).

        Delete-level (owner decision 2026-10-04): requires the `destructive:company_store`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Approved and cancelled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
                  order:
                    type: object
                  provider_cancellation:
                    type: object
                    nullable: true
        '403':
          description: "`insufficient_permissions` — the token has no `destructive:company_store`
            scope (see above); `forbidden` — not a store admin, the token's scope
            doesn't permit it, or a cash order without a business administrator."
        '409':
          description: "`resolved` (already decided), `not_cancellable` or `cancel_failed`
            (the order moved on)."
        '422':
          description: "`invalid` — not a cancellation request."
  "/company-store/admin/order-requests/{id}/decline":
    post:
      tags:
      - Company Store
      summary: 'Admin: decline a request'
      description: |
        The employee sees `reason` in their Inbox and by email. The order is unchanged — nothing is refunded.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:company_store` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: Declined.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
        '403':
          description: "`forbidden`."
        '409':
          description: "`resolved` — already decided."
  "/company-store/admin/order-requests/{id}/answer":
    post:
      tags:
      - Company Store
      summary: 'Admin: reply to a request'
      description: For questions, address changes and damage reports. `reply` is required
        and reaches the employee in their Inbox and by email.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reply
              properties:
                reply:
                  type: string
      responses:
        '200':
          description: Answered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    "$ref": "#/components/schemas/CompanyStoreOrderRequest"
        '403':
          description: "`forbidden`."
        '409':
          description: "`resolved` — already decided."
        '422':
          description: "`invalid` — empty reply."
  "/company-store/points":
    get:
      tags:
      - Company Store
      summary: Points balance, activity roll-up and transaction history
      description: |
        Everything the **Points** tab renders in one request: the wallet hero
        with its expiry nudge, the 90-day activity roll-up, the 6-month
        earned-vs-spent trend, the all-time breakdown arranged as a filter row
        with a **count per type**, the paginated transaction history, and the
        self-service tax-statement link.

        ### Whose points

        **The caller's own wallet, for every role.** There is no persona branch
        here and none on the web page either — an employee, a manager and a store
        admin all see their own points and nobody else's. Reading another
        employee's balance is the separate admin balances surface, which has its
        own gate and is not part of this namespace. `viewer` is reported so a
        client can offer those surfaces, but it changes nothing in this payload.

        ### The transaction history and its filter

        `type` narrows the list to one kind of ledger entry, using the same
        validated vocabulary the desktop Transaction Breakdown links and the
        `/m/` pill row use:

        | value | what it is | sign |
        |---|---|---|
        | *(absent)* | everything — the **All** pill | either |
        | `credit` | points earned (recognition, award, released pending points) | positive |
        | `debit` | points redeemed at checkout | negative |
        | `adjustment` | an admin correction, or points clawed back with a deleted recognition | **either** |
        | `expiry` | points aged out by the expiry policy (breakage) | negative |
        | `refund` | points a cancelled, declined or refunded order gave back | positive |

        `adjustment` being signed **either way** is the one to get right
        client-side: an admin adding points and an admin taking them back are both
        adjustments, which is exactly why every row carries `positive` and the web
        page colours the amount on the sign rather than on the type.

        ### Counts and the pill row

        `counts` carries **every** type plus `all`, always present (0 when empty),
        from ONE grouped query. `type_filters` is that same data arranged as the
        pill row — value, label, count, whether it is selected, and whether the
        web would show it.

        Two rules matter:

        * Counts are **all-time** and are **not** narrowed by `type`. Each pill
          has to report how many rows tapping it would land on, so narrowing by
          the active type would make every other pill read 0. This is the same
          rule the Orders endpoint's status pills follow. Note this means
          `counts.all` is the whole history even when the list is filtered —
          `meta.total_count` is the count of the **filtered** list.
        * `visible` follows the web's pill row: All, Earned, Redemption and Refund
          always; Adjustment and Expired only while they have rows or are
          selected (a tenant with expiry switched off never has an expired row,
          and a permanent "Expired 0" pill is noise). A client may ignore
          `visible` and render all six — it exists so the pill row can match the
          web without hardcoding the vocabulary.

        ### Windows are reported, never assumed

        `activity.period_days` is **90** — the window the points *screen* shows,
        which is deliberately **not** the dashboard widget's 30 days. Both
        surfaces report the window they computed so a client's header can't claim
        one the server didn't.

        `trend.months` always holds 6 entries oldest-first, including
        zero-activity months, so a chart renders at a stable width. `earned` and
        `spent` cover credits and debits only — adjustments and expiries land in
        neither series, because this is the earn-vs-redeem picture rather than a
        net-change chart. `trend.has_activity` is what the web page gates the
        whole card on: a flat all-zero chart is worse than no chart.

        ### Expiry

        `balance.expiring_points` is what **newly** expires within
        `balance.expiring_within_days` — the same figure the web banner copy
        claims and the same one the expiry warning notification sends, so the
        number can never disagree with the text beside it. It is **not** the total
        currently-expirable pool — except during a policy's notice period: when an
        admin switches expiry on or shortens it, nothing expires for 14 days, and
        `expiring_points` counts every point the new policy reaches (the warning
        sent that night names the same figure).

        Read it together with `features.points_expiry_enabled`: `0` means
        "nothing is close" when expiry is on, and "this tenant does not expire
        points" when it is off. Those want different copy, and the second should
        render no expiry banner or countdown at all.

        ### The tax statement

        `tax_statement.available` mirrors the web header button: offered only when
        the viewer actually has taxable redemptions in a year the statement page
        itself offers, because otherwise the link dead-ends on an empty statement.
        `tax_statement.year` is the year that **has** rows, scanned newest-first
        across the offered range — **not** the current year. In Jan–Apr those are
        usually different, which is exactly when the statement matters most, so a
        client must link to the year reported rather than to "this year".

        ### Gotchas

        * An unrecognised `type` is **ignored** (the response falls back to All)
          rather than returning nothing — `filters.type` reports what was actually
          applied, so a client can tell the difference.
        * `per_page` is clamped server-side to 50; `filters.per_page` and
          `meta.per_page` report the value in force.
        * `source` is a **reference** (`{type, id}`) plus `label`, the words
          the web ledgers print under a row ("Levels & Badges level-up",
          "Contest prize"). The label names the KIND of source, not the
          record: resolving the record would mean a polymorphic load per row.
          Fetch the underlying record by reference if a client needs its name.
        * `admin_user` is deliberately absent from adjustment rows: the
          notification an employee receives says "An admin added/removed …" and no
          web surface names the individual. `notes` **is** present — that is the
          admin's stated reason, which the same notification already sends.
      security:
      - BearerAuth: []
      parameters:
      - name: type
        in: query
        required: false
        description: Narrow to one kind of ledger entry. Blank/absent = All. An unrecognised
          value is IGNORED (falls back to All) rather than returning an empty list.
        schema:
          type: string
          enum:
          - credit
          - debit
          - adjustment
          - expiry
          - refund
      - name: page
        in: query
        required: false
        description: 1-based page number. Values below 1 are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Clamped to 50; a non-positive or junk value falls
          back to 20.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The caller's points balance, roll-ups, filter row and transaction
            page.
          content:
            application/json:
              schema:
                type: object
                properties:
                  points:
                    type: object
                    required:
                    - viewer
                    - features
                    - balance
                    - activity
                    - trend
                    - counts
                    - type_filters
                    - filters
                    - tax_statement
                    - transactions
                    - meta
                    properties:
                      viewer:
                        "$ref": "#/components/schemas/CompanyStorePointsViewer"
                      features:
                        "$ref": "#/components/schemas/CompanyStorePointsFeatures"
                      balance:
                        "$ref": "#/components/schemas/CompanyStorePointsBalance"
                      activity:
                        "$ref": "#/components/schemas/CompanyStorePointsActivity"
                      trend:
                        "$ref": "#/components/schemas/CompanyStorePointsTrend"
                      counts:
                        "$ref": "#/components/schemas/CompanyStorePointsTypeCounts"
                      type_filters:
                        type: array
                        description: The pill row, in its display order — All, Earned,
                          Redemption, Refund, Adjustment, Expired (the web's order).
                          The numbers are the same ones `counts` reports.
                        items:
                          "$ref": "#/components/schemas/CompanyStorePointsTypeFilter"
                      filters:
                        type: object
                        description: The filter values the server actually APPLIED
                          (not what was sent).
                        properties:
                          type:
                            type: string
                            nullable: true
                            description: null when no type filter is in force — including
                              when an unknown one was sent and dropped.
                            enum:
                            - credit
                            - debit
                            - adjustment
                            - expiry
                            - refund
                            -
                          per_page:
                            type: integer
                            description: The clamped page size in force.
                      tax_statement:
                        "$ref": "#/components/schemas/CompanyStorePointsTaxStatement"
                      transactions:
                        type: array
                        items:
                          "$ref": "#/components/schemas/CompanyStorePointsTransaction"
                      meta:
                        "$ref": "#/components/schemas/CompanyStorePointsPageMeta"
                  unread_notification_count:
                    type: integer
                    description: Native app badge count (the shared api/v1 envelope).
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: |
            One of three refusals, distinguished by `error.code`:

            * `insufficient_permissions` — the token lacks `read:company_store`.
              Checked first, before either gate below.
            * `access_denied` — the Company Store app isn't enabled for this
              tenant, or this user is outside the app's audience.
            * `store_disabled` — the tenant's admin has paused the store.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                        - store_disabled
                      message:
                        type: string
  "/company-store/watchlist":
    post:
      tags:
      - Company Store
      summary: Add an item to the watchlist
      description: |
        Saves one store item to the caller's own watchlist (`kind: wishlist`, the
        default) or subscribes them to its back-in-stock alert
        (`kind: restock`).

        **Idempotent.** Adding an item that is already watched is a `200` with
        `changed: false` and one watch row — never a duplicate, and never a
        toggle back off. This is the deliberate divergence from the web button;
        see this file's header.

        ### What comes back

        The state AFTER the write, read back from the database rather than
        assumed — `wishlisted` and `restock_watch` are resolved together in one
        query and are named exactly as the catalog reports them, so a client
        patches its cached card field-for-field without a second call.

        `wishlist_total` is `Store::DashboardStats#wishlist_total` — the very
        figure the dashboard's Saved Items header renders and
        `GET /company-store/dashboard` reports as `saved_items.total`. It is the
        **saved-items** scope, not a raw row count: it omits watches whose item an
        admin has since discontinued, unpublished or restricted to a group this
        caller isn't in, exactly as the dashboard grid omits them. So saving a
        discontinued item is honestly `watching: true` with an unmoved
        `wishlist_total` — the item IS saved, and is genuinely not on the Saved
        Items screen.

        No item card is returned: the caller just tapped the heart on a card it
        already holds, and this namespace has two card shapes (the shared
        dashboard-grid card and the catalog's extended one). Returning either
        would hand clients a third shape to reconcile for no new information.

        ### When `restock` is refused

        A back-in-stock alert is accepted only for an item that is currently
        unavailable AND could plausibly come back — the same condition the web
        detail page renders its button under. `StoreRestockNotifyJob` fires only
        on an `out_of_stock → active` flip, so an alert on an in-stock item would
        never notify anybody, and one on a `discontinued` / `draft` item promises
        a restock that is never coming. Both would be confirmed with "We'll notify
        you when it's back", which the store cannot honour, so both are `422
        restock_not_applicable`.

        Wishlist saves carry no such restriction — saving an unavailable item is
        the whole point of a wishlist, and the catalog card's `status` /
        `available` let a client say so.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_id
              properties:
                item_id:
                  type: integer
                  description: The StoreItem to watch — the `id` every catalog card
                    reports. Resolved within the caller's own business, so another
                    tenant's id is simply not found.
                  example: 412
                kind:
                  type: string
                  enum:
                  - wishlist
                  - restock
                  default: wishlist
                  description: 'Defaults to `wishlist`, so the common call is just
                    an `item_id`. An unrecognised value is `422 invalid_kind` rather
                    than a silent fallback: a client that sent `restok` meant to write
                    a restock alert, and quietly writing a wishlist row instead is
                    a save it never asked for.'
                region_id:
                  type: integer
                  description: 'Optional, and only relevant while the tenant has active
                    store regions: it selects which region the ADD is judged from,
                    exactly like the web region picker and `GET /company-store/catalog/{id}`.
                    Omit it and the caller''s own resolved region applies.'
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
              - item_id
              properties:
                item_id:
                  type: integer
                kind:
                  type: string
                  enum:
                  - wishlist
                  - restock
                  default: wishlist
                region_id:
                  type: integer
      responses:
        '200':
          description: Watched. `changed` is `true` when this call created the watch,
            `false` when it was already there.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistResponse"
        '400':
          description: '`invalid_item_id` — `item_id` was absent, non-numeric, non-positive
            or sent as an array. Answered separately from the `404` on purpose: "no
            such item" and "you didn''t send an item" are different client bugs, and
            an array `item_id` is refused rather than resolved to whichever id sorts
            first inside it (which would be a write against an item the caller never
            named).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`
            — checked first, before the app gate), `access_denied` (the app isn't
            accessible to this caller), `store_disabled` (an admin paused the store),
            `region_restricted` (the item belongs to another region) or `region_unavailable`
            (no store region resolves for this caller)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '404':
          description: "`not_found` — no such item in this business, **or** an item
            restricted to an audience group this caller isn't in. Deliberately the
            same answer with the same copy for both: naming the reason would disclose
            who is in the group."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '422':
          description: "`invalid_kind` (not one of `wishlist` / `restock`) or `restock_not_applicable`
            (the item is in stock, or is discontinued / draft, so the alert could
            never fire). Nothing is written."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
  "/company-store/watchlist/{item_id}":
    delete:
      tags:
      - Company Store
      summary: Remove an item from the watchlist
      description: |
        Removes the caller's own watch of one store item — their saved item
        (`kind: wishlist`, the default) or their back-in-stock alert
        (`kind: restock`).

        **Idempotent.** Removing a watch that isn't there is a `200` with
        `changed: false`, not a `404`: the caller asked for the item to be off
        their watchlist, and it is.

        Only the requested `kind` is removed — an item carrying both a wishlist
        save and a restock alert keeps the other one, and the response's
        `wishlisted` / `restock_watch` report both.

        ### No visibility guards here

        Unlike the ADD, this operation runs **neither** the region nor the
        audience guard. A watch saved before an admin restricted the item, or
        moved it to another region, is still the caller's own row — refusing to
        clear it would leave them holding a saved item they can see on no screen
        and cannot delete. The delete is scoped to the caller's own rows in their
        own business, so it can never reach anyone else's watch.

        A `404` here therefore means only one thing: no such item exists in this
        business (in which case no watch of it can exist either, since watches are
        deleted with their item).
      security:
      - BearerAuth: []
      parameters:
      - name: item_id
        in: path
        required: true
        schema:
          type: integer
        description: The StoreItem id every catalog card reports. Digit-constrained
          by the route, so a non-numeric value does not match this operation at all.
      - name: kind
        in: query
        required: false
        schema:
          type: string
          enum:
          - wishlist
          - restock
          default: wishlist
        description: A query parameter rather than a body field, because a DELETE
          body is not reliably forwarded by every client stack. An unrecognised value
          is `422 invalid_kind` and nothing is deleted.
      responses:
        '200':
          description: Not watched. `changed` is `true` when this call removed a watch,
            `false` when there was nothing to remove.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions`, `access_denied` or `store_disabled`
            — see the ADD operation."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '404':
          description: "`not_found` — no such item in this business."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
        '422':
          description: "`invalid_kind` — not one of `wishlist` / `restock`. Nothing
            is deleted."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreWatchlistError"
  "/recognitions/config":
    get:
      tags:
      - Recognitions
      summary: Recognitions configuration
      description: |
        **The first call a client makes.** One request returns everything needed
        to render the role-adaptive Recognitions shell and both composers, so a
        client never has to hardcode the points economy, the message limits, or
        which controls a given viewer may use.

        Every value here is read from the definition the **write path already
        enforces** — the give guard, the reviewer rule, the moderation rule, the
        program eligibility predicate, the model validations, the allowance
        model. So a control you render from this payload is a control whose POST
        the server will accept. Re-deriving any of it client-side is the drift
        this endpoint exists to remove.

        **Persona-aware, not persona-branched.** Every caller receives the SAME
        keys — nothing is omitted by role. Read `permissions` and `features`
        rather than probing for a missing key. What varies is the VALUE of those
        booleans and of the per-viewer economy figures.

        ### The five answers

        * **`module_enabled` / `module_label`** — is Recognize on here, and what
          is it called. Terminology varies per org ("Recognize", "Kudos"), and
          the label comes from the app record, so a console rename reaches the
          app with no client release. `module_enabled` is always `true` in a 200
          (the endpoint 403s otherwise) and is reported so one client model
          covers both answers.
        * **`viewer_role` + `permissions`** — `employee` / `manager` / `admin`,
          and the seven affordance booleans behind it. `manager` means *has
          direct reports*; `admin` means a business admin/owner or a Recognitions
          app-admin, and outranks `manager`.
        * **`features`** — the TENANT switches that decide which surfaces exist
          at all. Kept separate from `permissions` on purpose: hide a tab on
          `features`, disable a button on `permissions`. Collapsing them means an
          employee can't tell "this org doesn't do nominations" from "no program
          accepts me".
        * **`economy` / `limits` / `visibility_options`** — the real, admin
          configurable numbers and the values the server will actually accept.
        * **`values` / `tags` / `cards` / `programs`** — the give composer's and
          the nominate picker's option catalogs, shared verbatim with the web and
          mobile give forms.

        ### Composing a give from this payload

        `values`, `tags` and `cards` are exactly what the web give form offers.
        A card is submitted as its `award_template_id`, which is an **integer**
        for a tenant-authored design and the **string** `"central:<slug>"` for a
        central-gallery one (the server materialises the gallery art into a
        tenant asset on submit) — send the field verbatim rather than
        reconstructing it. `cards.gallery_truncated` is true when the gallery
        page came back full, meaning the catalog holds designs this payload did
        not list; say so in a picker's "no match" state.

        `visibility_options` lists the composer's visibility **and** anonymity
        toggles together, because they are one row of controls on the screen —
        each row names the `param` a client submits it under (`visibility` or
        `is_anonymous`). `department` is the prototype's "my_department"; the
        wire value is `department` because that is what the server stores.
        `private` is offered only when it is the tenant's own default, and
        `anonymous` only while the tenant allows anonymous recognition. The
        legacy `team` synonym is never offered.

        ### Composing a nomination

        `programs.items` is the nominate picker's vocabulary — trimmed to what a
        picker needs, with `can_nominate` resolved per viewer by the same
        predicate the submit path enforces and `nomination_block_reason`
        explaining a `false` in the viewer's own words (**render it** — a
        disabled row with no reason is a dead end). Automatic milestone programs
        are never listed: nobody nominates in them. The list is bounded by
        `programs_limit`; `total_count` and `truncated` report the rest, and the
        browsable, paginated surface is `GET /recognitions/programs`. When
        award requests ("Model A") are off, the list is empty and
        `permissions.can_nominate` is `false`.

        ### Cost

        Flat. Nothing in the payload costs a query per program, value or card,
        so this is safe to call on every launch. The central card gallery is
        cached and failure-tolerant — an unreachable gallery yields an empty
        `cards.gallery` rather than an error.
      security:
      - BearerAuth: []
      parameters:
      - name: programs_limit
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 12
        description: How many nominatable programs the picker vocabulary carries,
          clamped to 50. `programs.total_count` and `programs.truncated` report the
          rest. This is a composer vocabulary, not a browsable page — use `GET /recognitions/programs`
          to page through them.
      responses:
        '200':
          description: Configuration retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - config
                properties:
                  config:
                    "$ref": "#/components/schemas/RecognitionConfig"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/dashboard":
    get:
      tags:
      - Recognitions
      summary: Recognitions dashboard
      description: |
        The native-client mirror of the web Recognition dashboard. Every number
        and list is produced by the same query object that backs the web page, so
        the two surfaces cannot drift.

        **The response is persona- and setting-aware.** Keys that do not apply
        are **absent** (not null), exactly as the web page renders no card — read
        `viewer` and `features` to know which shape you received:

        * **Every viewer** gets `viewer`, `features`, the `recognition_received`
          and `recognition_given` stats, `recognition`, `my_awards`,
          `trending_recognition` and `top_recipients_this_month`.
        * **Givers** — anyone the tenant lets give peer recognition — additionally
          get `people_to_recognize`, the ranked nudge strip (up to 8 chips) the
          web feed shows above the stream. Absent for a viewer whose give would
          be refused.
        * **Reviewers** — a recognition admin, or anyone with direct reports —
          additionally get `pending_approvals`, the queue the web page banners
          above everything else. Recognition stays hidden from the recipient
          until the reviewer decides, so surfacing this promptly matters.
        * **Award requests on** (Model A — the default) adds
          `stats.nominations`, `my_nominations` and `active_programs`.
        * **Award cycles on** (Model B — off by default) adds `running_program`:
          the time-boxed cycle currently accepting nominations, or `null` when
          none is open.

        Visibility is enforced per row: `recognition` merges the tenant's public
        awards with only the posts this viewer may see (public, own,
        same-department, same-team), and a group give collapses to ONE row naming
        every recipient. Anonymous recognition never names the giver, and
        automated lifecycle awards (anniversaries, birthdays) present as
        `System (Automated)` rather than the system principal.

        List sizes mirror the web widgets: 5 rows each, except
        `trending_recognition` (3), `active_programs` (3) and
        `people_to_recognize` (8).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    required:
                    - viewer
                    - features
                    - stats
                    - recognition
                    - my_awards
                    - trending_recognition
                    - top_recipients_this_month
                    properties:
                      viewer:
                        type: object
                        description: Who the caller is, for the affordances the client
                          shows.
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Anthony Rivera
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey.../a.jpg
                          can_give_recognition:
                            type: boolean
                            description: Whether this viewer may give peer recognition
                              at all — the SAME guard the submit path enforces. Hide
                              the Give affordance when false; the POST would be rejected.
                            example: true
                          is_reviewer:
                            type: boolean
                            description: True for a recognition admin OR anyone with
                              direct reports. Determines whether `pending_approvals`
                              is present.
                            example: false
                          is_recognition_admin:
                            type: boolean
                            description: Business admin (or above), or a per-app admin
                              for Recognitions. Widens the approval queue from "assigned
                              to me" to the whole tenant.
                            example: false
                      features:
                        type: object
                        description: The tenant's nomination surfaces. Tells the client
                          which optional sections to expect, so a disabled surface
                          never has to be inferred from a missing key.
                        properties:
                          award_requests_enabled:
                            type: boolean
                            description: Model A — ad-hoc nominate → approve → award.
                              Default true.
                            example: true
                          award_cycles_enabled:
                            type: boolean
                            description: Model B — time-boxed award cycles. Default
                              false.
                            example: false
                      stats:
                        type: object
                        description: The three stat tiles. Received and Given each
                          roll an award count and a shout-out count into ONE total
                          — render `total` as the figure and the split as its sub-line,
                          which is how the web tile reads.
                        properties:
                          recognition_received:
                            type: object
                            description: Recognition the caller received (awards +
                              shout-outs).
                            properties:
                              total:
                                type: integer
                                example: 21
                              awards:
                                type: integer
                                example: 12
                              shout_outs:
                                type: integer
                                example: 9
                          recognition_given:
                            type: object
                            description: Recognition the caller gave (awards + shout-outs).
                            properties:
                              total:
                                type: integer
                                example: 62
                              awards:
                                type: integer
                                example: 28
                              shout_outs:
                                type: integer
                                example: 34
                          nominations:
                            type: object
                            description: Present only while award requests are enabled.
                              `awaiting_approval` counts how many of the caller's
                              OWN submitted nominations are still waiting on a decision
                              (pending or under review) — it is a subset of `submitted`.
                            properties:
                              submitted:
                                type: integer
                                example: 5
                              awaiting_approval:
                                type: integer
                                example: 2
                      people_to_recognize:
                        type: array
                        description: |-
                          **Givers only** — present when `viewer.can_give_recognition` is true, absent otherwise (the web strip is gated on the same rule, and a chip whose give would be refused is worse than no chip).
                          The "People to recognize" nudge strip: up to 8 colleagues this caller is most likely to want to recognize next, from the same query object as the web strip (`Recognition::SuggestedRecipientsQuery`).
                          **The order IS the signal — render it as given, never re-sort.** Ranking: people the caller recently shared SHIFTS with (most shifts first — a collaboration signal a standalone recognition tool can't see), then their direct reports, then same-department colleagues to fill the strip out. Direct reports are deliberately eligible for the shift tier too; recognizing your own report is a primary use case.
                          Tapping a chip should open the give composer with that person prefilled. An empty array means "nobody to suggest" (a caller with no shared shifts, no reports and no department) — render no strip, not an empty one.
                          The strip's trailing "Someone else" affordance (open the composer with no recipient) is client chrome and carries no row here — do not expect one.
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          - type: object
                            properties:
                              first_name:
                                type: string
                                description: The compact chip label — the strip shows
                                  a face and a first name, not a full name. Honors
                                  a preferred name, and falls back to the full display
                                  name rather than ever being blank.
                                example: Maya
                              department:
                                type: string
                                nullable: true
                                description: The chip's secondary line; null when
                                  the person has no department.
                                example: Store Operations
                              reason:
                                type: string
                                nullable: true
                                description: Why this person is suggested, as the
                                  strip's subtext. Only the shift-coworker tier carries
                                  one today; null for a direct-report or department
                                  fill suggestion.
                                example: Worked 3 shifts together
                              work_anniversary:
                                type: boolean
                                description: TODAY is this person's work anniversary
                                  — the highlight state (the web strip tints the chip
                                  and appends "anniversary"). Recognize-them-now,
                                  not a date to print.
                                example: false
                      pending_approvals:
                        type: object
                        description: Reviewers only. Recognition waiting on THIS viewer's
                          decision. The same reviewer rule and scopes as the pending-approvals
                          page, so this count always equals the queue it links to.
                        properties:
                          total:
                            type: integer
                            example: 3
                          posts:
                            type: integer
                            example: 2
                          nominations:
                            type: integer
                            example: 1
                          oldest_pending_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the longest-waiting item arrived; null
                              when the queue is empty.
                      recognition:
                        type: array
                        description: The merged activity stream — public awards plus
                          the posts this viewer may see, newest first (max 5).
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - award
                              - recognition_post
                              example: award
                            id:
                              type: integer
                              example: 9012
                            title:
                              type: string
                              nullable: true
                              description: The award's title; null for a peer shout-out.
                              example: Above and Beyond
                            message:
                              type: string
                              nullable: true
                              example: Stayed late three nights to get the store reset
                                done.
                            points:
                              type: integer
                              description: Value in reward points. Awards store dollars
                                and are converted here; peer posts carry points directly.
                              example: 250
                            recipient:
                              "$ref": "#/components/schemas/RecognitionPerson"
                            group_recipients:
                              type: array
                              nullable: true
                              description: Present ONLY on a collapsed group give
                                — every recipient of the one recognition, so the client
                                can render "X and N others". Null otherwise.
                              items:
                                "$ref": "#/components/schemas/RecognitionPerson"
                            giver:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                              description: Null when the recognition is anonymous.
                                An automated lifecycle award reports `System (Automated)`
                                with a null id.
                            anonymous:
                              type: boolean
                              example: false
                            program:
                              type: string
                              nullable: true
                              example: Spot Awards
                            category:
                              type: string
                              nullable: true
                              example: Customer Focus
                            company_value:
                              type: string
                              nullable: true
                              example: Teamwork
                            company_values:
                              type: array
                              description: Every company value it celebrates, primary
                                first — up to three.
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                    example: 4
                                  name:
                                    type: string
                                    example: Customer First
                            can_share_outside:
                              type: boolean
                              description: Whether the caller may share this outside
                                the company (`POST /moment_shares`) — only the person
                                it is about, while it is public and the company's
                                Shared moments switch is on, and only with a token
                                holding write:recognition.
                              example: false
                            occurred_at:
                              type: string
                              format: date-time
                      my_awards:
                        type: array
                        description: |-
                          Awards the caller received, newest first (max 5). Finalized only — revoked and expired awards are excluded, so this list always equals `stats.recognition_received.awards` (up to the cap).
                          **Page the rest with `GET /recognitions/my_recognition?tab=awards`, not `?tab=received`.** This card is an AWARDS-only preview; `received` merges peer shout-outs in, so sending its "View all" there shows rows the card never held. It also means the figure to render beside this card is `stats.recognition_received.awards` — NOT `stats.recognition_received.total`, which is awards + shout-outs. A recipient with shout-outs and no awards legitimately has an empty `my_awards`, a non-zero `total`, and nothing to "view all" of.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 771
                            title:
                              type: string
                              example: Customer Hero
                            message:
                              type: string
                              nullable: true
                              example: Turned a churn risk into a multi-year renewal.
                            points:
                              type: integer
                              example: 250
                            giver:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                              description: Null when the award was given anonymously.
                            program:
                              type: string
                              nullable: true
                              example: Excellence Awards
                            category:
                              type: string
                              nullable: true
                              example: Leadership
                            awarded_at:
                              type: string
                              format: date-time
                      my_nominations:
                        type: array
                        description: Present only while award requests are enabled.
                          Nominations the caller submitted, newest first (max 5).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 335
                            title:
                              type: string
                              example: Renewal save of the quarter
                            points:
                              type: integer
                              description: What the nomination is worth right now,
                                in reward POINTS — the approved figure once a reviewer
                                has set one, the requested figure until then. 0 when
                                the nomination was filed without a value.
                              example: 250
                            requested_points:
                              type: integer
                              nullable: true
                              description: The value the nominator asked for, in reward
                                points. Null (not 0) when none was specified.
                              example: 250
                            approved_points:
                              type: integer
                              nullable: true
                              description: The value a reviewer granted, in reward
                                points. Null until the nomination is approved.
                              example: 250
                            nominee:
                              "$ref": "#/components/schemas/RecognitionPerson"
                            program:
                              type: string
                              nullable: true
                              example: Excellence Awards
                            category:
                              type: string
                              nullable: true
                              example: Outstanding Performance
                            status:
                              type: string
                              enum:
                              - pending
                              - under_review
                              - approved
                              - rejected
                              - cancelled
                              example: pending
                            status_label:
                              type: string
                              description: The humanized status the web row prints.
                              example: Pending
                            awaiting_approval:
                              type: boolean
                              description: True while the status is pending or under
                                review.
                              example: true
                            submitted_at:
                              type: string
                              format: date-time
                              nullable: true
                      trending_recognition:
                        type: array
                        description: 'The most-engaged PUBLIC recognition of the last
                          24 hours (max 3). Same row shape as `recognition`, plus
                          the engagement counts the web card prints. Deliberately
                          public-only: this is org-wide social proof, so it must not
                          surface a department/team/private recognition.'
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              example: recognition_post
                            id:
                              type: integer
                              example: 4471
                            title:
                              type: string
                              nullable: true
                              description: The award's title; null for a peer shout-out.
                              example: Above and Beyond
                            message:
                              type: string
                              nullable: true
                            points:
                              type: integer
                              example: 25
                            recipient:
                              "$ref": "#/components/schemas/RecognitionPerson"
                            group_recipients:
                              type: array
                              nullable: true
                              description: Present ONLY on a collapsed group give
                                — every recipient of the one recognition. Null otherwise.
                              items:
                                "$ref": "#/components/schemas/RecognitionPerson"
                            giver:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                            anonymous:
                              type: boolean
                              example: false
                            program:
                              type: string
                              nullable: true
                              example: Spot Awards
                            category:
                              type: string
                              nullable: true
                              example: Customer Focus
                            company_value:
                              type: string
                              nullable: true
                              example: Teamwork
                            company_values:
                              type: array
                              description: Every company value it celebrates, primary
                                first — up to three.
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                    example: 4
                                  name:
                                    type: string
                                    example: Customer First
                            reactions_count:
                              type: integer
                              example: 14
                            comments_count:
                              type: integer
                              example: 3
                            occurred_at:
                              type: string
                              format: date-time
                      active_programs:
                        type: array
                        description: Present only while award requests are enabled.
                          The programs a nomination can actually be filed under (max
                          3). Milestone programs are excluded — anniversaries and
                          birthdays are granted automatically and are never nominated.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 12
                            name:
                              type: string
                              example: Spot Awards
                            description:
                              type: string
                              nullable: true
                              example: Peer-to-peer recognition for great work, any
                                time.
                            program_type:
                              type: string
                              enum:
                              - peer_to_peer
                              - manager_to_employee
                              - achievement_based
                              example: peer_to_peer
                            icon:
                              type: string
                              description: Font Awesome class derived from the program
                                type — the same glyph the web card shows. Programs
                                carry no icon of their own.
                              example: fas fa-users
                            can_nominate:
                              type: boolean
                              description: Server-side eligibility for THIS caller
                                — the same answer as `GET /recognitions/programs`.
                                False when peer recognition is turned off for the
                                organization, for a manager-only program a non-manager
                                is viewing, or one whose criteria they don't meet
                                — don't open the nominate form; the POST would be
                                rejected.
                              example: true
                            nomination_block_reason:
                              type: string
                              nullable: true
                              description: Why `can_nominate` is false, in the viewer's
                                words; null when they can nominate. Same text as the
                                Programs list.
                              example:
                      top_recipients_this_month:
                        type: array
                        description: The month's most-recognized people (max 5), counting
                          BOTH awards and peer shout-outs — recognition is recorded
                          on two tables and a single-table leaderboard undercounts
                          it.
                        items:
                          type: object
                          properties:
                            rank:
                              type: integer
                              description: 1-based position in this list.
                              example: 1
                            user:
                              allOf:
                              - "$ref": "#/components/schemas/RecognitionPerson"
                              nullable: true
                              description: Null when the recipient has since left
                                the business. Their count still stands, so `name`
                                is always sent.
                            name:
                              type: string
                              example: Patrick Smith
                            count:
                              type: integer
                              example: 4
                      running_program:
                        type: object
                        nullable: true
                        description: Present only while award cycles are enabled.
                          The time-boxed cycle currently accepting nominations — null
                          when none is open, in which case render nothing rather than
                          an empty card. When several are open, the one closing soonest
                          wins.
                        properties:
                          id:
                            type: integer
                            example: 7
                          title:
                            type: string
                            example: Employee of the Quarter — Q3
                          description:
                            type: string
                            nullable: true
                            example: Recognize a standout performer for the quarter.
                          icon:
                            type: string
                            description: Cycles carry no icon; the web banner uses
                              the trophy.
                            example: fas fa-trophy
                          days_left:
                            type: integer
                            description: Whole days until the window closes, floored
                              at 0 (0 = closes today).
                            example: 5
                          closes_at:
                            type: string
                            format: date-time
                          total_nominations:
                            type: integer
                            description: Nominations pooled in this cycle so far,
                              from everyone.
                            example: 14
                          can_nominate:
                            type: boolean
                            description: 'Both halves of the server''s own gate: the
                              cycle is still accepting AND this viewer may give recognition.'
                            example: true
                          program_id:
                            type: integer
                            example: 12
                          program_name:
                            type: string
                            example: Award cycles
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/employee_suggestions":
    get:
      tags:
      - Recognitions
      summary: Employee suggestions for giving recognition
      description: |
        The colleagues this caller may pick as a recognition **recipient** — the
        native-client mirror of the web recipient typeahead
        (`GET /recognition/employee_suggestions`), which backs the give composer,
        both nominate forms and the manager Quick Award.

        Both surfaces read ONE query object
        (`Recognition::EmployeeSuggestionsQuery`), so who is suggestible, what a
        search matches and the order rows come back in are the same code. A
        colleague findable in the app is findable here, and vice versa.

        **Givers only.** This is the one endpoint in the Recognitions API gated on
        giving access, because a recipient roster is only ever read in order to
        give. A tenant that switches peer recognition off leaves giving to admins
        and managers, and the submit path enforces exactly the same rule — so a
        caller is never handed a roster whose give would be refused. Everyone else
        gets 403 `giving_not_allowed` rather than a browsable directory of their
        colleagues.

        **Who is listed:** active members of *this* business only. Never the
        caller (the submit path strips them out of the recipient set anyway),
        never a member the admin deactivated, never a service / AI-agent
        principal, never another tenant's user.

        **Search (`q`)** matches, case-insensitively and on any substring, against
        the `name` column, `first_name`, `last_name`, `preferred_name`, `email`,
        and the `"<first> <last>"` / `"<preferred> <last>"` forms. LIKE wildcards
        (`%`, `_`) are treated as literal characters. A blank `q` returns the
        whole roster alphabetically, which is what a picker opens with.

        **Suggestions, not a ranked feed.** The work-context nudge ("People to
        recognize" — recent shift coworkers, then direct reports, then department)
        is a different, un-paginated list and ships on
        `GET /recognitions/dashboard`; it is not duplicated here.

        `scope=direct_reports` applies the Quick Award narrowing: for a
        **non-admin** it restricts the roster to their own direct reports, which is
        the recipient rule the Quick Award submit path enforces. An admin may award
        anyone, so nothing is narrowed for them — read `meta.direct_reports_only`
        (not the `scope` you sent) to decide "your team only" copy.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Search term. Matched against name, first/last/preferred name,
          email and the two "<first> <last>" / "<preferred> <last>" forms. Blank means
          the whole roster.
        example: maya
      - name: scope
        in: query
        required: false
        schema:
          type: string
          enum:
          - all
          - direct_reports
          default: all
        description: "`direct_reports` narrows a non-admin caller to their own reports
          (the Quick Award recipient rule). An unrecognized value falls back to `all`
          rather than erroring."
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based page number.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
        description: Rows per page, clamped to 100. Defaults to 25, matching the web
          picker, so the two surfaces page identically.
      responses:
        '200':
          description: Suggestions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - employees
                - meta
                properties:
                  employees:
                    type: array
                    description: Suggestible colleagues for this page, ordered by
                      name (with id as a stable tiebreak, so no one appears on two
                      pages).
                    items:
                      "$ref": "#/components/schemas/RecognitionEmployeeSuggestion"
                  meta:
                    "$ref": "#/components/schemas/RecognitionEmployeeSuggestionsMeta"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked notification badge count.
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: |
            Either the Recognitions app isn't accessible to this caller
            (`access_denied`), or the caller may not give recognition
            (`giving_not_allowed`) — the same rule the submit path enforces. No
            roster is included in a refusal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - access_denied
                        - giving_not_allowed
                      message:
                        type: string
  "/recognitions/quick_award":
    get:
      tags:
      - Recognitions
      summary: Quick Award form options
      description: |
        **Everything the Quick Award form renders from**, in one call — the native
        mirror of the web "Give an Instant Award" screen (Team ▸ Quick Award) and
        of the native design's **Team ▸ + ▸ Quick Award** sheet.

        A Quick Award is a manager awarding points to a team member **instantly,
        with no approval step** — the counterpart to a nomination, which goes
        through review. It draws against the manager's giving budget when the
        tenant configures one.

        Both this endpoint and the web page read one shared object
        (`Recognition::QuickAwardOptions`), and the POST below resolves its
        default program through the same object — so what is **offered** here is
        what that screen offers, and what the server will **accept**.

        ### Who may call it

        **Reviewers only, in a tenant that has Quick Awards on** — a Recognitions
        admin, or anyone with direct reports. That is exactly the pair
        `GET /recognitions/config` reports as `permissions.can_quick_award`, so
        read that flag to decide whether to show the affordance at all rather than
        probing this endpoint. The two refusals are separate codes on purpose:
        `feature_disabled` means the org doesn't do Quick Awards (hide it),
        `forbidden` means this person isn't a reviewer (it isn't theirs).

        Per-**recipient** authority is a different question, answered by
        `recipient_scope`: an admin may award anyone, a manager only their own
        direct reports — which is the rule the POST enforces on the row. Call
        `GET /recognitions/employee_suggestions?scope={recipient_scope}` to fill
        the picker and it can never offer somebody the POST would reject.

        ### Every figure is in POINTS

        Reward points are the program-wide unit, shared with peer gives and the
        Company Store. The columns store dollars (1 pt = 1¢) and the server
        converts on the way in and out, so a client never handles dollars —
        `amount.points_per_dollar` is there for the rare screen that shows a
        currency figure.

        ### Composing the form from this payload

        * **`amount`** — the input's floor, ceiling and pre-filled value, plus
          `presets`, the web's own one-tap ladder already filtered to the tenant's
          cap. Never widen the input past `max_points`; the POST refuses it.
        * **`limits`** — what the two text fields will actually accept. Both are
          optional (the server substitutes a default when either is blank), but
          `message_min` is the one that surprises callers: a note that IS sent has
          to clear 10 characters, or the POST answers 422 `invalid`.
        * **`programs`** — active, in-window programs by name, each with only its
          **active** categories. A category carries its own point range
          (`min_points` / `max_points` / `default_points`, `null` meaning
          unbounded on that side) and a program may carry
          `per_award_limit_points`, its own ceiling on a single award. Bound the
          amount input by the tightest of the three.
        * **`require_category`** — whether the category field is mandatory. Render
          the asterisk from this; the server enforces it.
        * **`default_program_id`** — what "Default Program" resolves to, and the
          program the POST funds the award from when `program_id` is omitted. The
          `budget` below is that program's budget, so the two agree.
        * **`budget`** — the manager's remaining giving budget, every figure in
          points. `{ "enabled": false }` for most tenants: businesses without
          group budgets are unaffected. `blocked` is the web submit button's own
          disabled condition — disable Give on it rather than re-deriving
          "exhausted or nothing left".
        * **`recent_awards`** — this manager's last few awards in this tenant, the
          web sidebar's confirmation list. Deliberately smaller than a feed card;
          the full card is one request away at `GET /recognitions/awards/{id}`.

        ### Cost

        Flat. Nothing costs a query per program or per category, so this is safe
        to call every time the sheet opens.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The form's options
          content:
            application/json:
              schema:
                type: object
                required:
                - quick_award
                properties:
                  quick_award:
                    "$ref": "#/components/schemas/RecognitionQuickAwardForm"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app isn't accessible to this caller (`access_denied`),
            the tenant has Quick Awards switched off (`feature_disabled`), or the
            caller is not a reviewer (`forbidden`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardRefusal"
    post:
      tags:
      - Recognitions
      summary: Give a Quick Award
      description: |
        **Award points to a team member instantly, with no approval step.** The
        native mirror of the web "Give an Instant Award" submit
        (`POST /recognition/quick_award`) and of the mobile Quick Award form.

        Runs `Recognition::QuickAwardService`, the canonical creator every non-web
        quick-award surface runs, so the direct-report recipient rule, the amount
        bounds, the anti-gaming guard, the program per-award cap, the category
        requirement and the per-manager group budget are the same code everywhere.
        The award is created **active** — it is live the moment this returns 201,
        the points are credited to the recipient's Company Store balance, and the
        recipient is notified.

        Gated identically to the GET above. Fill every picker from it.

        ### The fields

        `recipient_id` and `amount` are the only required ones — that is the
        minimum the native sheet collects. Everything else has a documented
        server-side default:

        * **`amount`** is in **POINTS**, within `amount.min_points` ..
          `amount.max_points`, and also within the chosen program's
          `per_award_limit_points` and the chosen category's range when either is
          set.
        * **`program_id`** omitted funds the award from `default_program_id`. A
          `program_id` that is not an active program in this tenant is refused
          with `invalid_program` — it is never silently swapped for the default,
          which would charge a program the caller did not choose.
        * **`category_id`** must belong to the chosen program — a category id from
          a *different* program is silently ignored rather than refused, matching
          the web form, where a category can only be picked after its program.
          Required when `require_category` is true.
        * **`title`** blank becomes `"Quick Award from {giver name}"`.
        * **`message`** blank becomes `"Great work! Keep it up."`. A value that
          IS sent must clear `limits.message_min` (10 characters).
        * **`is_public`** **defaults to `true`**, because the web checkbox is
          pre-checked — send `false` explicitly to keep the award off the
          recognition feed and out of any connected Slack/Teams channel.
        * **`anniversary_years`** is what the anniversary roster's **Recognize**
          action adds, and the ONLY thing that makes this award count as that
          person's work-anniversary recognition — send the row's own `years`.
          Omit it for an ordinary spot award. See the field description for what
          it records.

        ### The response

        `award` is the same **feed card** `GET /recognitions/feed` and
        `GET /recognitions/awards/{id}` return, so the card a client inserts
        optimistically is the card it gets back on the next refresh. `message` is
        the web flash word for word — show it as-is. `budget` is the state
        **after** the spend, so a budget header refreshes without a second call.

        Unlike a peer give, a Quick Award never lands in a pending state: there is
        no moderation hold and no approval routing, so a 201 always means the
        award is live.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionQuickAwardRequest"
            examples:
              minimal:
                summary: The native sheet's minimum — a person and an amount
                value:
                  recipient_id: 412
                  amount: 500
              full:
                summary: The web form, every field
                value:
                  recipient_id: 412
                  program_id: 7
                  category_id: 31
                  amount: 2500
                  title: Shipped the migration
                  message: You carried the migration all weekend — thank you.
                  is_public: true
              private:
                summary: Keep it off the feed
                value:
                  recipient_id: 412
                  amount: 500
                  is_public: false
              anniversary:
                summary: The anniversary roster's Recognize action — records the recognition
                value:
                  recipient_id: 412
                  amount: 500
                  title: 10 Year Work Anniversary
                  anniversary_years: 10
      responses:
        '201':
          description: The award is live, the points are credited
          content:
            application/json:
              schema:
                type: object
                required:
                - award
                - message
                properties:
                  award:
                    "$ref": "#/components/schemas/RecognitionQuickAwardCard"
                  message:
                    type: string
                    description: The web flash, word for word. Safe to show as-is;
                      a 201 always means the award is live.
                    example: Successfully awarded Priya Nair with 500 points! They've
                      been credited to their account.
                  budget:
                    "$ref": "#/components/schemas/RecognitionQuickAwardBudget"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied`, `feature_disabled` or `forbidden` — the same
            three refusals, with the same wording, as the GET above. A rule that guarded
            only the read would be a rule a direct POST walked past."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardRefusal"
        '422':
          description: |
            The award was refused and **nothing was written**. `error.code` says
            which rule, so a client can highlight the offending field instead of
            showing a generic banner:

            | code | the field to fix |
            |---|---|
            | `invalid_recipient` | `recipient_id` — no such person in this tenant |
            | `recipient_not_permitted` | `recipient_id` — not one of your direct reports (an admin may award anyone) |
            | `invalid_amount` | `amount` — zero or negative |
            | `amount_over_limit` | `amount` — above the tenant cap, the program's `per_award_limit_points`, or the category's range. `details` carries the bounds. |
            | `category_required` | `category_id` — this tenant requires one |
            | `governance_blocked` | none — the tenant's monthly give cap or duplicate-recipient cooldown. `message` names the limit and when it lifts; show it verbatim. |
            | `budget_exceeded` | `amount` — the manager's giving budget can't cover it. `budget` reports what's left. |
            | `invalid_program` | `program_id` — you named a program that isn't an active one in this tenant. Re-fetch the picker from `GET /recognitions/quick_award`. Omit `program_id` to award from the default program instead. |
            | `no_program` | none — the tenant has no active recognition program. An admin has to create one. |
            | `invalid` | a model validation, most often `message` below `limits.message_min`. `message` carries the validation's own wording. |

            Note `recipient_not_permitted` is **422, not 403**: the endpoint *is*
            for this caller, it is the recipient field that is wrong — highlight
            the picker rather than hiding the screen.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardError"
        '500':
          description: "`internal_error` — an unexpected failure while creating the
            award. Distinguished from the 422s so a client retries rather than telling
            the manager their input was wrong."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionQuickAwardError"
  "/recognitions/my_recognition":
    get:
      tags:
      - Recognitions
      summary: My Recognition — the caller's own all-time record
      description: |
        The native-client mirror of the web **My Recognition** page
        (`/recognition/my_recognition`). Both surfaces read the same query object
        (`Recognition::MyRecognitionQuery`), so the rows, the totals and the
        reward-points wallet cannot drift between them.

        **Always self-scoped.** There is no `user_id` parameter and one sent
        anyway is ignored — this endpoint can only ever return the token holder's
        own recognition. The manager-tier reads for someone else's history live on
        the separate Recognition Connect integration API
        (`/recognition/user_awards`, `/recognition/user_nominations`), which carry
        their own authorization.

        **One list per tab.** Recognition lives on two tables — program/manager
        awards and peer shout-outs. The web page renders them as stacked sections
        with independent cursors; this endpoint merges them into ONE newest-first
        list behind a single cursor, so the mobile screen is one scrolling card
        list. Every row carries `type` (`award` / `recognition_post` /
        `nomination`) and shares an envelope, so one client component can render
        them all. `awards` and `nominations` are single-type tabs — `awards` is
        `received` with the shout-outs removed, and is what the dashboard's
        "My awards" card pages into.

        **Everything is all-time**, because this is the lifetime history hub: the
        headline totals, the tab badges and the tab lists are the same numbers and
        agree with one another. `/recognition/wrapped` is the separate
        current-year recap, and the two intentionally differ for anyone with
        prior-year activity.

        **Where the numbers deliberately disagree.** `summary.total_given` counts
        FINALIZED gives only, so it matches the web tab badge and My Wrapped. The
        Given LIST additionally carries gives still awaiting approval or
        moderation — flagged `pending: true` with an `approval_state` — so a
        just-submitted give stays visible — and gives a reviewer turned down
        more than 30 days ago, flagged `approval_state: rejected` (the last 30
        days' are the uncounted `not_approved` group instead, never both). That
        is why `meta.total_count` on the Given tab equals `summary.total_given +
        summary.pending_given + summary.rejected_given` and can exceed
        `total_given`. On Received and Nominations, `meta.total_count`
        equals the matching `summary` total exactly.

        Anonymous recognition never names the giver (`giver: null`,
        `anonymous: true`), and automated lifecycle awards (anniversaries,
        birthdays) present as `System (Automated)` with a null id rather than
        leaking the system principal.

        **Every row carries the same `permissions` block the feed card and the
        detail screen ship**, so one card component renders the same ⋯ menu
        wherever it meets a recognition and never an affordance the server would
        refuse. Each flag is the canonical predicate the write endpoint itself
        enforces, so this is the one part of the payload where an admin's answer
        differs from an employee's. Four things behave differently here than on
        the feed, all of them because of what this screen serves:

        * **`can_boost` is always `false`.** Every row is the caller's own give
          or their own receipt, and boosting either is refused — which is also
          why this endpoint reports no `boost` block at all.
        * **`can_comment` / `can_react` are `false` on a pending give**, not just
          when the tenant switched them off. A recognition still awaiting
          approval is not live, so neither request would be accepted — matching
          the zero `engagement` the same row reports. Feed rows are always live,
          so the feed can report the tenant switch alone.
        * **`can_delete` is `true` on a pending give the caller authored.** An
          author may withdraw their own in-flight give; `can_edit` is `false` on
          the same row, because only an active recognition is editable. The two
          are not one gate.
        * **`can_delete` is withheld on award rows** (the same card-level choice
          the feed makes — an award's delete is the admin revoke, confirmed on
          the detail screen). Read `can_delete` from
          `GET /recognitions/awards/{id}` before hiding a revoke entry.
          `can_edit` is *not* withheld: a moderator may fix an award's message.

        A `nomination` row carries the block too, with every flag `false` — a
        nomination is not a recognition yet, and no edit, withdraw, share,
        comment or reaction endpoint accepts one. The approve/reject decisions
        live on the reviewer's own queue, never on the nominator's history row.
      security:
      - BearerAuth: []
      parameters:
      - name: tab
        in: query
        required: false
        schema:
          type: string
          enum:
          - received
          - given
          - nominations
          - awards
          default: received
        description: |-
          Which record to page over. `nominations` requires award requests (Model A) to be enabled for the tenant; asking for it when it is off — like any unrecognized value — serves `received` rather than erroring, the same fallback the web page redirects that deep-link to. Read `meta.tab` for what was actually served and `meta.available_tabs` for what this tenant offers.
          `awards` is `received` with the peer shout-outs removed — every row is an `award`. It is the destination for the dashboard "My awards" card's own "View all": that card is the top 5 of THIS list, so the rows match and `meta.total_count` here equals `stats.recognition_received.awards` there. `summary` is unchanged on this tab (still the four all-time tiles), so a client can render the tile row from any tab.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based. A missing, zero, negative or non-numeric value is page
          1.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        description: Clamped to 1..50.
      responses:
        '200':
          description: My Recognition retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - reward_points
                - summary
                - meta
                properties:
                  items:
                    type: array
                    description: The requested page, newest first. Mixed row types
                      on Received/Given; `nomination` rows only on Nominations. Key
                      off `type` — the award-only and post-only fields are present
                      as null on the other types so the envelope never changes shape.
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          enum:
                          - award
                          - recognition_post
                          - nomination
                          example: award
                        id:
                          type: integer
                          example: 481
                        title:
                          type: string
                          nullable: true
                          description: The award or nomination title. Null for a peer
                            shout-out, which has none.
                          example: Above & Beyond
                        message:
                          type: string
                          nullable: true
                          description: The award description, the post body, or the
                            nomination description.
                          example: Nailed the migration under real pressure.
                        points:
                          type: integer
                          description: Reward points, on EVERY row type — the figure
                            a client's shared recognition card renders. An award stores
                            dollars and converts (1 pt = 1¢); a peer post stores points
                            directly; a nomination converts its approved value, or
                            its requested value until one is approved. 0 when the
                            row carries no value.
                          example: 250
                        value:
                          type: string
                          nullable: true
                          description: Awards only — the stored DOLLAR figure, as
                            a decimal string. Use `points` unless you specifically
                            mean money.
                          example: '2.5'
                        display_value:
                          type: string
                          nullable: true
                          description: Awards only — the label the web row shows ("Priceless"
                            when unvalued).
                          example: 250 pts
                        recipient:
                          "$ref": "#/components/schemas/RecognitionPerson"
                        group_recipients:
                          type: array
                          nullable: true
                          description: Present only on a group give that collapsed
                            into one row, naming every recipient.
                          items:
                            "$ref": "#/components/schemas/RecognitionPerson"
                        giver:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          nullable: true
                          description: Null when the recognition is anonymous. For
                            an automated lifecycle award, `name` is "System (Automated)"
                            and `id` is null.
                        anonymous:
                          type: boolean
                          example: false
                        program:
                          type: string
                          nullable: true
                          example: Spot Awards
                        category:
                          type: string
                          nullable: true
                          example: Teamwork
                        company_value:
                          type: string
                          nullable: true
                          example: Ownership
                        company_values:
                          type: array
                          description: Every company value it celebrates, primary
                            first — up to three.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 4
                              name:
                                type: string
                                example: Customer First
                        visibility:
                          type: string
                          nullable: true
                          description: Posts only — public / department / team / private.
                          example: public
                        tags:
                          type: array
                          description: Posts only. Always an array (never null).
                          items:
                            type: string
                          example:
                          - teamwork
                        photo_url:
                          type: string
                          nullable: true
                          description: Absolute URL of the photo attached to this
                            one recognition, already a processed rendition.
                        award_art_url:
                          type: string
                          nullable: true
                          description: Absolute URL of the Asset Library award card
                            this recognition was given with.
                        certificate_url:
                          type: string
                          nullable: true
                          description: Awards only — the printable certificate the
                            web row links to.
                          example: "/recognition/awards/481/card"
                        engagement:
                          type: object
                          description: Reaction and comment counts. Always zero for
                            a pending give — nothing can react to a recognition that
                            is not live yet.
                          properties:
                            reactions_count:
                              type: integer
                              example: 4
                            comments_count:
                              type: integer
                              example: 1
                        permissions:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPermissions"
                          description: What this caller may do with THIS row — the
                            same block the feed card and the detail screen ship, and
                            the only part of the payload where an admin's answer differs
                            from an employee's. Present on all three row types. `can_boost`
                            is always false here, `can_comment` / `can_react` are
                            false on a pending give, `can_delete` is withheld on award
                            rows, and every flag is false on a `nomination` — see
                            the endpoint description for why.
                        pending:
                          type: boolean
                          description: A give still awaiting approval or moderation.
                            Present on the Given tab only; excluded from `summary.total_given`
                            but counted in `meta.total_count`.
                          example: false
                        approval_state:
                          type: string
                          nullable: true
                          enum:
                          - posting
                          - pending_approval
                          - pending_review
                          - rejected
                          -
                          description: Why a give is not live yet. `posting` is the
                            automated content check (the normal path, no human is
                            holding it); `pending_approval` / `pending_review` need
                            a reviewer to act; `rejected` is a give a reviewer turned
                            down more than 30 days ago (the reason is on its permalink).
                        status:
                          type: string
                          nullable: true
                          description: Nominations only.
                          example: pending
                        display_status:
                          type: string
                          nullable: true
                          description: Nominations only — the humanized status the
                            web badge shows.
                          example: Pending
                        status_color:
                          type: string
                          nullable: true
                          description: Nominations only — the hex the web badge is
                            painted with.
                          example: "#ffc107"
                        approval_level:
                          type: integer
                          nullable: true
                          description: Nominations only — which approval step is pending
                            ("Level 1 of 2").
                          example: 1
                        approval_levels_required:
                          type: integer
                          nullable: true
                          example: 2
                        nominee:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          nullable: true
                          description: Nominations only.
                        reviewer:
                          allOf:
                          - "$ref": "#/components/schemas/RecognitionPerson"
                          nullable: true
                          description: Nominations only — null while unassigned.
                        requested_value:
                          type: string
                          nullable: true
                          description: Nominations only — the dollar value asked for,
                            as a decimal string. Use `requested_points` (or `points`)
                            unless you specifically mean money.
                          example: '2.5'
                        approved_value:
                          type: string
                          nullable: true
                          description: Nominations only — the dollar value granted,
                            null until approved.
                        requested_points:
                          type: integer
                          nullable: true
                          description: Nominations only — `requested_value` in reward
                            POINTS, the unit the row's `points` and every other figure
                            in this API are denominated in. Null (not 0) when no value
                            was asked for, so "not specified" is distinguishable from
                            "worth nothing".
                          example: 250
                        approved_points:
                          type: integer
                          nullable: true
                          description: Nominations only — `approved_value` in reward
                            points. Null until the nomination is approved.
                          example: 250
                        reviewer_notes:
                          type: string
                          nullable: true
                          description: Nominations only — the reviewer's note, including
                            a rejection reason.
                        justification:
                          type: string
                          nullable: true
                          description: Nominations only.
                        occurred_at:
                          type: string
                          format: date-time
                          description: When the recognition happened — awarded_at,
                            published_at (created_at for a give not yet published),
                            or the nomination's submitted_at. The field the list is
                            sorted by.
                        reviewed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Nominations only.
                  not_approved:
                    type: array
                    description: 'Given tab only (absent on every other tab). The
                      caller''s own gives a reviewer turned down in the last 30 days,
                      newest decision first, one row per request (a group give names
                      every recipient). UNCOUNTED — never in `items`, `summary` or
                      `meta.total_count` — so a turned-down give and its reason stay
                      findable after the notice is read. Returned whole on every page
                      of the Given tab (it is not paginated; at most 50 requests).
                      Once a decision is older than 30 days the give moves to `items`,
                      flagged `approval_state: rejected`. `content` is whole and `recipients`
                      / `company_value_ids` (primary first; `company_value_id` is
                      the primary) are what a client''s give screen prefills from
                      to send it again.'
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        recipients:
                          type: array
                          items:
                            type: object
                            description: The same person card `items` rows carry,
                              plus `recognizable` — false for someone who has left
                              or is the caller, whom a resend must not name.
                            properties:
                              recognizable:
                                type: boolean
                        recipient_names:
                          type: string
                          example: Bob Lee and Carla Diaz
                        content:
                          type: string
                        company_value_id:
                          type: integer
                          nullable: true
                        company_value:
                          type: string
                          nullable: true
                        company_value_ids:
                          type: array
                          items:
                            type: integer
                          description: Every value it celebrated, primary first —
                            what a client's own give screen prefills from.
                        company_values:
                          type: array
                          description: Every company value it celebrates, primary
                            first — up to three.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 4
                              name:
                                type: string
                                example: Customer First
                        reason:
                          type: string
                          nullable: true
                          description: The reviewer's words.
                        decided_at:
                          type: string
                          format: date-time
                          nullable: true
                        decided_by:
                          type: string
                          nullable: true
                          description: Always null — automated declines record a system
                            or fallback reviewer, so no name is given; `reason` says
                            who decided where it matters.
                  reward_points:
                    type: object
                    description: 'The caller''s reward-points wallet. Reported whether
                      or not the Company Store is reachable for this user — points
                      are earned through recognition either way — so use `store_available`
                      to decide whether to offer the redeem/history affordances, not
                      whether to show the balance. Read-only: a GET never creates
                      the wallet row.'
                    properties:
                      store_available:
                        type: boolean
                        description: Whether the Company Store app is enabled for
                          the tenant AND this user is inside its audience.
                        example: true
                      points_balance:
                        type: integer
                        description: Points the caller can spend now.
                        example: 450
                      pending_points:
                        type: integer
                        description: Earned but not yet settled (recognition still
                          awaiting approval).
                        example: 25
                      lifetime_points_earned:
                        type: integer
                        example: 1200
                      lifetime_points_spent:
                        type: integer
                        example: 750
                      expiring_points:
                        type: integer
                        description: Points that will have aged past the tenant's
                          expiry window by the time the warning notification fires,
                          so this number and that notification agree. 0 when the tenant
                          has not configured expiry.
                        example: 150
                      points_expiry_months:
                        type: integer
                        description: The tenant's expiry window in months; 0 means
                          points never expire.
                        example: 12
                  summary:
                    type: object
                    description: The four headline tiles / tab badges, byte-identical
                      to the web page's own, plus `pending_given` and `total_awards`.
                      All all-time, and identical on every tab.
                    properties:
                      total_received:
                        type: integer
                        description: Awards received + shout-outs received. Equals
                          the Received tab's `meta.total_count`.
                        example: 12
                      total_awards:
                        type: integer
                        description: The awards half of `total_received` — shout-outs
                          excluded. Equals the Awards tab's `meta.total_count`, and
                          the dashboard's `stats.recognition_received.awards`.
                        example: 4
                      total_given:
                        type: integer
                        description: Awards given + shout-outs authored, FINALIZED
                          only — matching the web badge and My Wrapped. Gives awaiting
                          approval are counted in `pending_given` instead.
                        example: 28
                      total_nominations:
                        type: integer
                        description: Nominations the caller submitted, every status.
                          Equals the Nominations tab's `meta.total_count`.
                        example: 5
                      pending_nominations:
                        type: integer
                        description: Of those, the ones still awaiting a decision.
                        example: 2
                      pending_given:
                        type: integer
                        description: 'Gives still awaiting approval or moderation.
                          Present in the Given LIST (flagged `pending: true`) but
                          never in `total_given`.'
                        example: 1
                      rejected_given:
                        type: integer
                        description: 'Gives turned down more than 30 days ago. Present
                          in the Given LIST (flagged `approval_state: rejected`) but
                          never in `total_given`; the last 30 days'' are `not_approved`.'
                        example: 0
                  meta:
                    type: object
                    properties:
                      tab:
                        type: string
                        enum:
                        - received
                        - given
                        - nominations
                        - awards
                        description: The tab actually served — compare with your request
                          to detect a coercion.
                        example: received
                      available_tabs:
                        type: array
                        description: The tabs this tenant offers. `nominations` is
                          absent when award requests (Model A) are disabled; the other
                          three are always present.
                        items:
                          type: string
                        example:
                        - received
                        - given
                        - nominations
                        - awards
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_count:
                        type: integer
                        description: Rows in the whole list this tab pages over —
                          the real total, not the size of the page.
                        example: 12
                      total_pages:
                        type: integer
                        example: 1
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                      capabilities:
                        type: object
                        properties:
                          can_give_recognition:
                            type: boolean
                            description: Whether the caller may give peer recognition
                              at all — the SAME guard the submit path enforces. Hide
                              the Give affordance when false; the POST would be rejected.
                            example: true
                          award_requests_enabled:
                            type: boolean
                            description: Model A — gates the Nominations tab and the
                              Nominate affordance.
                            example: true
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/wrapped":
    get:
      tags:
      - Recognitions
      summary: Recognition Wrapped — the caller's personal year-in-recognition recap
      description: |
        The native-client mirror of the web **Recognition Wrapped** screen
        (`/recognition/wrapped`) — a Spotify-Wrapped-style recap of the caller's
        recognition for one calendar year: how much they gave and received, the
        company values they were celebrated for, and the people they exchanged the
        most recognition with. Both surfaces read the same service
        (`Recognition::WrappedService`) and the same year-resolution helpers, so
        the numbers and the year range cannot drift between them.

        **Always self-scoped.** There is no `user_id` parameter and one sent anyway
        is ignored — this endpoint can only ever return the token holder's own
        recap. The manager-tier reads for someone else's history live on the
        separate Recognition Connect integration API, which carries its own
        authorization.

        **Scoped to one calendar year by `created_at`.** `?year=` selects the
        recap year; a missing, non-numeric or out-of-range value serves the current
        year — the same clamp the web page applies to a hand-typed year. Read
        `meta.year` for what was actually served and `meta.available_years` for the
        range this tenant offers (its creation year through the current year,
        newest first).

        **Empty is a state, not an error.** A caller with no recognition this year
        gets `has_data: false` with zeroed tiles and empty lists (HTTP 200) — the
        client renders the "no recognition yet" empty state rather than treating it
        as a failure.

        The `top_champions` / `people_you_lifted_up` lists are capped at 3 and
        exclude the caller's own self-recognition; `celebrated_for` is capped at 3,
        most-tagged first.
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: query
        required: false
        schema:
          type: integer
        description: The recap year. A missing, non-numeric or out-of-range value
          serves the current year (see `meta.year` / `meta.available_years`).
      responses:
        '200':
          description: The recap retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - has_data
                - received
                - given
                - points_earned
                - celebrated_for
                - top_champions
                - people_you_lifted_up
                - highlights
                - meta
                properties:
                  has_data:
                    type: boolean
                    description: Whether the caller gave or received anything this
                      year. When false the tiles are zero and the lists empty — render
                      the empty state.
                    example: true
                  received:
                    type: object
                    description: The "Recognition received" tile — total and its shout-out/award
                      split.
                    required:
                    - total
                    - shout_outs
                    - awards
                    properties:
                      total:
                        type: integer
                        example: 12
                      shout_outs:
                        type: integer
                        description: Peer recognition posts received.
                        example: 8
                      awards:
                        type: integer
                        description: Program/manager awards received.
                        example: 4
                  given:
                    type: object
                    description: The "Recognition given" tile — total and its shout-out/award
                      split.
                    required:
                    - total
                    - shout_outs
                    - awards
                    properties:
                      total:
                        type: integer
                        example: 5
                      shout_outs:
                        type: integer
                        example: 3
                      awards:
                        type: integer
                        example: 2
                  points_earned:
                    type: integer
                    description: Reward points earned from peer shout-outs this year
                      (awards are not counted here).
                    example: 250
                  celebrated_for:
                    type: array
                    description: Up to 3 company values the caller was recognized
                      for, most-tagged first. `count` is the number of recognitions
                      tagged to that value — the web card's progress bars are relative
                      to the first (largest) row.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 17
                        name:
                          type: string
                          example: Teamwork
                        count:
                          type: integer
                          example: 6
                  top_champions:
                    type: array
                    description: Up to 3 people who recognized the caller most this
                      year. Never includes the caller.
                    items:
                      "$ref": "#/components/schemas/WrappedPersonCount"
                  people_you_lifted_up:
                    type: array
                    description: Up to 3 people the caller recognized most this year.
                    items:
                      "$ref": "#/components/schemas/WrappedPersonCount"
                  highlights:
                    type: object
                    description: The web "Highlights" card's lines. The first two
                      are null when there's nothing to say.
                    required:
                    - busiest_month
                    - first_received_on
                    - given_count
                    - received_count
                    properties:
                      busiest_month:
                        type: string
                        nullable: true
                        description: The calendar month the caller received the most
                          (English month name), or null if nothing was received.
                        example: March
                      first_received_on:
                        type: string
                        format: date
                        nullable: true
                        description: The date of the caller's first recognition this
                          year (ISO 8601), or null.
                        example: '2026-03-04'
                      given_count:
                        type: integer
                        description: Echoes `given.total`, so the card is self-contained.
                        example: 5
                      received_count:
                        type: integer
                        description: Echoes `received.total`.
                        example: 12
                  meta:
                    type: object
                    required:
                    - year
                    - available_years
                    - capabilities
                    properties:
                      year:
                        type: integer
                        description: The year actually served (after clamping).
                        example: 2026
                      available_years:
                        type: array
                        description: The years a recap can be requested for — the
                          tenant's creation year through the current year, newest
                          first.
                        items:
                          type: integer
                        example:
                        - 2026
                        - 2025
                        - 2024
                      capabilities:
                        type: object
                        properties:
                          can_give_recognition:
                            type: boolean
                            description: Whether the caller may give peer recognition
                              — the SAME guard the submit path enforces. Hide the
                              "Give Recognition" affordance when false.
                            example: true
                          can_share_outside:
                            type: boolean
                            description: '"Share my year" — whether the caller may
                              share this recap outside the company (`POST /moment_shares`,
                              subject_type `year_in_recognition`): only while the
                              year holds any recognition, the company''s Shared moments
                              switch is on and the token holds write:recognition.'
                            example: true
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/feed":
    get:
      tags:
      - Recognitions
      summary: Recognition feed (filtered, paginated)
      description: |
        The browsable recognition feed — the native-client mirror of the web
        feed page. Both surfaces read the same query object, so the rows, their
        order, the visibility rules and the filters are identical.

        **What the caller sees** is decided entirely by visibility, never by a
        role flag:

        * the tenant's **public awards** (non-public awards are never returned), and
        * **recognition posts** that are public, addressed to or written by the
          caller, `department`-scoped and authored by someone in the caller's
          department, or `team`-scoped and authored by a teammate.
        * `private` posts are returned **only** to their author and recipient.

        Only recognition from the **last 30 days** is in scope — the feed is
        "what's happening", not an archive.

        A **group give** (one recognition sent to several people) collapses into
        a single item whose `group_recipients` names everyone, rather than
        repeating a near-identical card per recipient.

        **Roles are emergent.** An employee, a manager and an admin all run the
        same query; they differ only in what it returns and in each item's
        `permissions` block. `my_team` resolves to a manager's direct reports,
        and to an employee's peers (their manager's reports).
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: |
          Which slice of the feed to return. Same five the web page offers;
          an unrecognised value falls back to `all` rather than erroring.
            * `all`           — awards and posts together (default)
            * `awards`        — public awards only
            * `posts`         — peer recognition posts only
            * `my_team`       — recognition given to or by the caller's team.
                                Direct reports (+ self) for a manager; the
                                caller's manager's reports (+ that manager)
                                for an individual contributor. Falls back to
                                the unfiltered feed when the caller has
                                neither reports nor a manager.
            * `my_department` — recognition given to or by anyone in the
                                caller's department. Unfiltered when the
                                caller has no department.
        schema:
          type: string
          enum:
          - all
          - awards
          - posts
          - my_team
          - my_department
          default: all
      - name: page
        in: query
        required: false
        description: 1-based page number. Values below 1 are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page. Clamped to 50.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The requested page of the feed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionFeedItem"
                  meta:
                    "$ref": "#/components/schemas/RecognitionFeedMeta"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/leaderboard":
    get:
      tags:
      - Recognitions
      summary: Leaderboard — top recipients and top givers
      description: |
        The native-client mirror of the web **Recognition Leaderboard**
        (`/recognition/leaderboard`). Both surfaces read the same query object
        (`Recognition::LeaderboardQuery`), so the period windows, the tables the
        counts are drawn from, the category basis switch, the tie-break and the
        caller's own rank cannot drift between them.

        **Two boards, five rows each.** `top_recipients` ranks who was recognized
        most; `top_givers` ranks who recognized others most. The web page shows 20
        rows per board and this endpoint shows 5 — same query, different depth,
        reported back as `meta.limit`.

        **No role branching.** The board is business-wide: an employee, a manager
        and a recognition admin receive exactly the same rows in the same order.
        The only caller-specific parts of the payload are `my_standing`, the
        `is_me` flag on a row, and `meta.capabilities`. Access is gated the same
        way as every other endpoint in this namespace — the Recognitions app must
        be enabled for the tenant AND the caller must be inside the app's
        audience, otherwise 403 `access_denied`.

        **Recognition lives on two tables** — formal awards and peer shout-outs.
        The default "All Categories" view counts BOTH, so peer recognition is not
        undercounted. Peer shout-outs carry no category, so selecting a category
        necessarily switches the basis to awards-only:
        `meta.counting_basis` becomes `awards_only` and `meta.basis_note` carries
        the sentence to show the viewer. **Render that note** — without it a giver
        whose recognition is mostly shout-outs appears to have fallen off the
        board for no visible reason.

        **What is never counted:** revoked or expired awards and non-active posts
        (they are filtered out of every other surface, so counting them here would
        credit recognition that was taken back), another tenant's recognition, and
        — on the givers board only — anonymous gives, since naming an anonymous
        giver on a public board is exactly the disclosure they opted out of, and
        automated lifecycle awards (anniversaries, birthdays, service
        milestones), which are written by a system principal rather than by any
        person. The recipient board is unaffected by either exclusion: it hides
        who gave, not that someone was recognized, and a work-anniversary award
        is recognition its recipient genuinely received.

        **Two different meanings of "rank".** A row's `rank` is its 1-based
        POSITION in the list — what the medallion renders (gold #1, silver #2,
        bronze #3, neutral from #4 down). `my_standing.*.rank` is competition
        style against the FULL board, so ties share a number and two people tied
        for the lead are both rank 1. `my_standing.*.rank` is `null` — unranked,
        not last — when the caller has no activity in the period; render nothing
        rather than a zero row. Use `my_standing.*.in_top` to decide whether to
        pin a "You — #47" footer row, so it is never a duplicate of a row already
        drawn above.

        **Filter values are coerced, not rejected.** An unrecognized `period`
        serves `month`; a `category` slug that is unknown, deleted or deactivated
        serves `all`. Always render `meta.period` / `meta.category` rather than
        echoing what was sent, or the filter chrome will claim a filter that was
        never applied.
      security:
      - BearerAuth: []
      parameters:
      - name: period
        in: query
        required: false
        schema:
          type: string
          enum:
          - week
          - month
          - quarter
          - year
          default: month
        description: Rolling window ending now — `month` is the last ~30 days, not
          the calendar month. An unrecognized value serves `month` rather than erroring;
          read `meta.period` for what was applied and `meta.period_start` / `meta.period_end`
          for the exact window.
      - name: category
        in: query
        required: false
        schema:
          type: string
          default: all
        description: A RecognitionCategory slug from `meta.available_categories`,
          or `all`. A slug is matched across EVERY active category carrying it — the
          same slug can exist under more than one program — so picking "Teamwork"
          counts all of them. An unknown, deleted or deactivated slug serves `all`;
          read `meta.category` and `meta.counting_basis`.
      responses:
        '200':
          description: Leaderboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - top_recipients
                - top_givers
                - my_standing
                - meta
                properties:
                  top_recipients:
                    type: array
                    description: Who was recognized most in the window, highest first.
                      At most `meta.limit` rows. Empty when nobody was recognized
                      — render the empty state, not an error.
                    items:
                      "$ref": "#/components/schemas/RecognitionLeaderboardRow"
                  top_givers:
                    type: array
                    description: Who recognized others most in the window, highest
                      first. Anonymous gives are excluded, so someone who only gave
                      anonymously does not appear.
                    items:
                      "$ref": "#/components/schemas/RecognitionLeaderboardRow"
                  my_standing:
                    type: object
                    description: The caller's own position on each FULL board (not
                      the visible slice), so a client can pin a "You" footer row when
                      they rank below the top N.
                    required:
                    - recipient
                    - giver
                    properties:
                      recipient:
                        "$ref": "#/components/schemas/RecognitionLeaderboardStanding"
                      giver:
                        "$ref": "#/components/schemas/RecognitionLeaderboardStanding"
                  meta:
                    type: object
                    required:
                    - period
                    - available_periods
                    - period_start
                    - period_end
                    - category
                    - available_categories
                    - counting_basis
                    - basis_note
                    - givers_empty_reason
                    - limit
                    - capabilities
                    properties:
                      period:
                        type: string
                        enum:
                        - week
                        - month
                        - quarter
                        - year
                        description: The period ACTUALLY applied, after coercion.
                        example: month
                      available_periods:
                        type: array
                        items:
                          type: string
                        example:
                        - week
                        - month
                        - quarter
                        - year
                      period_start:
                        type: string
                        format: date-time
                        description: Inclusive start of the rolling window.
                        example: '2026-07-13T17:49:10.082Z'
                      period_end:
                        type: string
                        format: date-time
                        description: Inclusive end of the window — the time the request
                          was served.
                        example: '2026-08-13T17:49:10.084Z'
                      category:
                        type: string
                        description: The category slug ACTUALLY applied, or `all`.
                          An unknown or deactivated slug reports back as `all`.
                        example: all
                      available_categories:
                        type: array
                        description: The filter options, `all` first, then the active
                          categories of active programs. Deduplicated by slug — two
                          programs can each define "Teamwork" and the filter covers
                          both.
                        items:
                          type: object
                          properties:
                            slug:
                              type: string
                              example: teamwork
                            name:
                              type: string
                              example: Teamwork
                      counting_basis:
                        type: string
                        enum:
                        - awards_and_shout_outs
                        - awards_only
                        description: Which tables produced the numbers. `awards_only`
                          whenever a category is applied, because peer shout-outs
                          carry no category.
                        example: awards_and_shout_outs
                      basis_note:
                        type: string
                        nullable: true
                        description: The sentence to show the viewer alongside the
                          filter when the basis narrowed. Null on the All Categories
                          view. Render it — otherwise a giver's rank collapsing under
                          a category filter reads as a bug.
                        example: Counting formal awards only — peer shout-outs aren't
                          category-tagged, so they're excluded from this view.
                      givers_empty_reason:
                        type: string
                        nullable: true
                        description: Non-null only when `top_givers` is EMPTY because
                          every give in this view was excluded (awarded by automation,
                          or anonymous) while people were still recognized — a category
                          such as Work Anniversary is awarded entirely by automation,
                          so it has no human givers at all. Null whenever the board
                          has rows, and null when nothing was recognized (a plain
                          "no activity yet" state is honest there). Render this instead
                          of a generic "be the first to recognize someone" prompt,
                          which would urge an action nobody can take in that view.
                        example: Recognition here was awarded automatically or given
                          anonymously, so no one is credited on this board.
                      limit:
                        type: integer
                        description: Maximum rows per board.
                        example: 5
                      capabilities:
                        type: object
                        properties:
                          can_give_recognition:
                            type: boolean
                            description: Whether THIS caller may give peer recognition
                              — the same guard the submit path enforces, so a client
                              never offers a Give button whose POST would be rejected.
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/leaderboard/categories":
    get:
      tags:
      - Recognitions
      summary: Leaderboard category filter options
      description: |
        The **Filter by category** options for the Leaderboard, on their own so a
        client can build the picker without pulling a board first (the mobile
        filter sheet opens before any board is chosen).

        This is the SAME list the board's own `meta.available_categories` is built
        from — active categories of active programs, `all` first, **deduplicated
        by slug** (a slug is unique per program, not per business, so two programs
        can each define "Teamwork" and the filter covers both with one option).
        Because both surfaces read the one query object, the picker can never
        offer a category the board would then reject.

        Each real option is enriched beyond the board's slug+name pair with the
        `color`, `icon` and `description` a colored filter chip needs, so the
        picker renders without a second round-trip. Those display fields fall back
        to a neutral swatch (`#95a5a6`) and a `star` icon when the tenant left
        them blank, so a client never has to invent a default.

        The list is **business-wide and identical for every role**, exactly like
        the board it filters — the endpoint takes no parameters. Selecting any
        category narrows the board to formal awards only (peer shout-outs carry no
        category); `meta.counting_basis_note` is the sentence to surface next to
        the picker so that narrowing never reads as a bug.

        One query serves the whole list; every field is a column already on the
        loaded row, so there is no per-category lookup regardless of how many
        categories the tenant has.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Filter options retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - categories
                - meta
                properties:
                  categories:
                    type: array
                    description: The picker options, `all` first, then one option
                      per active category of an active program, ordered by the category's
                      sort order then name, deduplicated by slug.
                    items:
                      type: object
                      required:
                      - slug
                      - name
                      - is_all
                      properties:
                        slug:
                          type: string
                          description: The RecognitionCategory slug to pass as the
                            board's `?category=` param, or `all` for the sentinel.
                          example: teamwork
                        name:
                          type: string
                          example: Teamwork
                        is_all:
                          type: boolean
                          description: True only for the "All Categories" sentinel,
                            so a client selects "no filter" without string-matching
                            the slug.
                          example: false
                        color:
                          type: string
                          nullable: true
                          description: Hex swatch for a filter chip. Null on the `all`
                            sentinel; a neutral `#95a5a6` when the tenant left the
                            category's color blank.
                          example: "#3498db"
                        icon:
                          type: string
                          nullable: true
                          description: Icon name for the chip. Null on the `all` sentinel;
                            a `star` fallback when the tenant left it blank.
                          example: users
                        description:
                          type: string
                          nullable: true
                          description: The category's description, or null when blank.
                          example: Working well together
                  meta:
                    type: object
                    required:
                    - count
                    - counting_basis_note
                    properties:
                      count:
                        type: integer
                        description: The number of REAL categories offered — the `all`
                          sentinel is NOT counted, so this can be shown as "5 categories"
                          honestly.
                        example: 4
                      counting_basis_note:
                        type: string
                        description: 'The sentence to show next to the picker: selecting
                          any category counts formal awards only, because peer shout-outs
                          carry no category. Mirrors the board''s `meta.basis_note`,
                          shown BEFORE a category is applied rather than after.'
                        example: Selecting a category counts formal awards only —
                          peer shout-outs aren't category-tagged, so they're excluded
                          from a category view.
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
  "/recognitions/programs":
    get:
      tags:
      - Recognitions
      summary: Recognition programs
      description: |
        The native-client mirror of the web Programs page. Every program, count,
        points figure and CTA is produced by the same query object that backs the
        web page (`Recognition::ProgramsQuery`), so the two surfaces cannot drift.

        **Two lists, because they answer different questions:**

        * `programs` — the ones a nomination can actually be filed under.
          Paginated (12 per page, matching the web grid). This is the same basis
          as the dashboard's `active_programs` card, so that card's own "View All"
          can never land on a longer list than it counted.
        * `automatic_programs` — birthdays, work anniversaries and tenure
          milestones, awarded by the lifecycle job. Nobody nominates in these, so
          they are a separate section rather than padding the first one. Served
          whole (a tenant has a handful at most) and every row reports
          `can_nominate: false` with the automatic explanation.

        Only **active** programs **inside their start/end window** are listed. A
        draft, retired, not-yet-open or closed program is absent from both lists.

        **The role story is `can_nominate`, and nothing else.** There is no
        role-scoped hiding: an employee, a manager and an admin all receive the
        same programs. What differs is whether each card offers the CTA, and that
        answer comes from the program's own eligibility rule — the SAME predicate
        the nomination submit path enforces, so a client is never offered a button
        whose POST would be rejected:

        * a `peer_to_peer` program is open to everyone;
        * a `manager_to_employee` program is open to **managers only**, both ways
          — an admin who is not a manager is blocked too;
        * an `achievement_based` program with nominator `eligibility_criteria`
          (tenure, department, location, role) answers per profile;
        * a `milestone` program is never nominatable by anyone.

        When `can_nominate` is false, `nomination_block_reason` says why in the
        viewer's own words. **Render it** — a disabled button with no reason and
        no next step is a dead end, and the web card shows the same sentence.

        `reward_points` presents the program's monthly recognition budget in the
        POINTS employees see everywhere else in this app (the economy is stored in
        dollars; 1 point = 1¢, at the tenant's `points_per_dollar` store setting).
        The dollar figures ride along for consumers that report currency.

        Requires ad-hoc award requests ("Model A") to be enabled for the tenant —
        the same gate that hides the web Programs tab. A tenant with it off gets
        403 `feature_disabled` rather than a list of programs nobody there can
        nominate in.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based page number. Paginates `programs` only — `automatic_programs`
          is served whole on every page.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 12
        description: Cards per page, clamped to 50. Defaults to 12, matching the web
          grid, so the two surfaces page identically.
      responses:
        '200':
          description: Programs retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - programs
                - automatic_programs
                - meta
                properties:
                  programs:
                    type: array
                    description: Programs this viewer can browse and (subject to `can_nominate`)
                      nominate in. Ordered by name. Paginated.
                    items:
                      "$ref": "#/components/schemas/RecognitionProgramCard"
                  automatic_programs:
                    type: array
                    description: 'System-awarded milestone programs — every row has
                      `is_automatic: true`, `can_nominate: false` and `my_nominations:
                      0`. Not paginated.'
                    items:
                      "$ref": "#/components/schemas/RecognitionProgramCard"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 12
                      total_count:
                        type: integer
                        description: Nominatable programs across all pages. Describes
                          `programs` only; `automatic_programs` is served whole, so
                          its length IS its total.
                        example: 3
                      total_pages:
                        type: integer
                        example: 1
                      has_next_page:
                        type: boolean
                        example: false
                      has_prev_page:
                        type: boolean
                        example: false
                      automatic_programs_count:
                        type: integer
                        description: Length of `automatic_programs`.
                        example: 2
                      capabilities:
                        type: object
                        properties:
                          can_nominate_any:
                            type: boolean
                            description: Whether this viewer can nominate in ANY program
                              on this page — the web header's "Nominate Someone" button.
                              False means the nominee picker would be empty for them,
                              so hide the entry point.
                            example: true
                          can_give_recognition:
                            type: boolean
                            description: Whether this viewer may give peer recognition
                              at all — the same guard the submit path enforces.
                            example: true
                          award_requests_enabled:
                            type: boolean
                            description: Model A. Always true in a 200 response —
                              the endpoint 403s when it is off — and reported so a
                              client can cache one settings shape.
                            example: true
                          award_cycles_enabled:
                            type: boolean
                            description: Model B (time-boxed award cycles). Independent
                              of this endpoint; reported so a client knows whether
                              the Award Cycles surface exists for this tenant.
                            example: false
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or ad-hoc award requests are disabled for the tenant, which also hides
            the web Programs tab (error code `feature_disabled`).
  "/recognitions/team":
    get:
      tags:
      - Recognitions
      summary: Team Recognition
      description: |
        The native-client mirror of the web **Team Recognition** page. Every
        number, list and scope is produced by the same query object that backs
        the web page (`Recognition::TeamStats`), so the two surfaces cannot drift
        on what a number means, who is on "my team", or who may see a row.

        **Reviewer-only.** The web page is gated on the canonical
        `User#recognition_reviewer?` — a business admin, a Recognitions app
        admin, or anyone with direct reports — and redirects everyone else. This
        endpoint refuses with **403 `forbidden`** rather than returning an
        all-zero payload, which a client would render as "your team has never
        been recognized". Check `viewer.is_reviewer` on the dashboard endpoint
        before offering the Team tab.

        **Manager vs admin.** `viewer.is_recognition_admin` changes the reach of
        two sections, and the client should label them accordingly:

        * `pending_approvals` — an admin's queue spans the whole tenant; a
          manager's is limited to nominations they may actually review (assigned
          to them, unassigned, or about one of their own reports) plus the posts
          routed to them.
        * `upcoming_anniversaries` — an admin sees the whole active workforce; a
          manager sees only their own team.

        `team_members` is always the viewer's OWN reports, for both personas —
        which is why an admin with nobody reporting to them gets an empty roster
        beside a full approvals queue. The web page says exactly that in its
        empty state.

        **Counting rules**, applied identically to every tile and list:

        * Recognition lives on two tables — formal **awards** and peer
          **shout-outs** — and every received/given number rolls up both. Every
          shout-out counts, not only point-bearing ones: a tenant that doesn't
          use redeemable rewards collects no points on the give form at all.
        * Revoked awards are excluded everywhere (their store points have been
          clawed back).
        * Private posts are excluded everywhere **except** `needs_recognition`.
          That panel prints no content, and someone recognized privately HAS been
          recognized — flagging them would nag the manager into a duplicate.

        All values that represent money are reported in **reward points**;
        storage is in dollars and the conversion happens server-side, so a client
        never needs the tenant's points-per-dollar rate.
      parameters:
      - name: team_scope
        in: query
        required: false
        schema:
          type: string
          enum:
          - direct
          - all
          default: direct
        description: "`direct` (default) lists the viewer's direct reports; `all`
          widens to their whole org subtree. A **request, not the answer** — when
          no direct report has reports of their own, `all` is the same set, so the
          server forces the response back to `direct` and reports `scope.has_indirect_reports:
          false`. Read `scope.current`, never the value you sent, and only offer the
          toggle when `scope.has_indirect_reports` is true."
      responses:
        '200':
          description: The Team screen
          content:
            application/json:
              schema:
                type: object
                properties:
                  team:
                    type: object
                    properties:
                      viewer:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          is_reviewer:
                            type: boolean
                            description: Always true in a 200 — the gate refuses everyone
                              else. Sent so the payload is self-describing.
                            example: true
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin. Widens `pending_approvals` to the tenant
                              and `upcoming_anniversaries` to the whole workforce.
                            example: false
                          can_give_recognition:
                            type: boolean
                            description: Whether the tenant lets THIS user give peer
                              recognition — the same guard the submit path enforces,
                              so a Give button whose POST would be rejected is never
                              offered.
                            example: true
                      features:
                        type: object
                        description: Tenant toggles, mirrored (not re-derived) from
                          the same settings that gate the web nav and views, so a
                          client can hide exactly the surfaces the web page hides.
                        properties:
                          award_requests_enabled:
                            type: boolean
                            description: Model A — ad-hoc award requests. Defaults
                              on.
                            example: true
                          award_cycles_enabled:
                            type: boolean
                            description: Model B — time-boxed award cycles. Defaults
                              off.
                            example: false
                          quick_award_enabled:
                            type: boolean
                            description: Instant manager award, no approval step.
                              Gates the Recognize / Celebrate button the web page
                              hangs off every roster, gap and anniversary row.
                            example: true
                          peer_points_enabled:
                            type: boolean
                            description: Whether a peer allowance pool exists at all.
                              When false, `my_giving` carries program budgets only.
                            example: false
                      scope:
                        type: object
                        properties:
                          current:
                            type: string
                            enum:
                            - direct
                            - all
                            description: The scope actually in force — read this,
                              not the request.
                            example: direct
                          has_indirect_reports:
                            type: boolean
                            description: Whether any direct report has reports of
                              their own. False → the `all` toggle is meaningless;
                              don't render it.
                            example: false
                          label:
                            type: string
                            description: Ready-to-render label matching `current`.
                            example: Direct reports
                      stats:
                        type: object
                        description: The four tiles the screen leads with. Received
                          and Given carry the awards-vs-shout-outs split alongside
                          the total (the total IS the split), so a client can render
                          the sub-line without a second request.
                        properties:
                          recognition_received:
                            "$ref": "#/components/schemas/RecognitionTeamCountTile"
                          recognition_given:
                            "$ref": "#/components/schemas/RecognitionTeamCountTile"
                          total_value:
                            type: object
                            properties:
                              points:
                                type: integer
                                description: The team's total recognition value in
                                  reward points, converted from the dollars awards
                                  are stored in.
                                example: 4250
                          team_members:
                            type: object
                            properties:
                              total:
                                type: integer
                                description: Always equals the length of `team_members`
                                  — the tile and the roster count the same set.
                                example: 6
                              scope:
                                type: string
                                enum:
                                - direct
                                - all
                                description: Which scope produced this count.
                                example: direct
                      my_giving:
                        type: array
                        description: One row per pool the viewer can give from — the
                          monthly peer allowance and each award-program budget they
                          draw on. Empty when the tenant has configured neither.
                        items:
                          "$ref": "#/components/schemas/RecognitionTeamGivingPool"
                      pending_approvals:
                        type: object
                        description: The viewer's approval queue. Recognition stays
                          hidden from the recipient until a reviewer decides, so a
                          reviewer who never learns they have a queue silently blocks
                          it.
                        properties:
                          total:
                            type: integer
                            description: '`nominations_count + posts_count` — the
                              badge number. Counting nominations alone left reviewers
                              reading "Pending (0)" while posts sat unapproved.'
                            example: 3
                          nominations_count:
                            type: integer
                            description: The FULL queue size, computed before the
                              preview limit — never capped at the number of rows below.
                            example: 2
                          posts_count:
                            type: integer
                            example: 1
                          nominations:
                            type: array
                            description: 'The first 5 nominations. Pending POSTS are
                              deliberately not previewed: an unapproved post is unpublished
                              recognition, so its body is shown nowhere until it is
                              approved — send the reviewer to the approvals screen
                              for those.'
                            items:
                              "$ref": "#/components/schemas/RecognitionTeamPendingNomination"
                      needs_recognition:
                        type: object
                        description: The fairness signal — who is going unrecognized.
                        properties:
                          count:
                            type: integer
                            description: The FULL number flagged. `members` is capped
                              at 8, so render "+N more" from the difference rather
                              than implying the list is complete.
                            example: 3
                          threshold_days:
                            type: integer
                            description: A member is flagged after this long with
                              nothing at all.
                            example: 30
                          members:
                            type: array
                            description: Never-recognized first, then longest gap
                              first. At most 8 rows.
                            items:
                              "$ref": "#/components/schemas/RecognitionTeamGapRow"
                      upcoming_anniversaries:
                        type: object
                        properties:
                          window_days:
                            type: integer
                            description: How far ahead the list looks.
                            example: 30
                          entries:
                            type: array
                            description: Soonest first, at most 5.
                            items:
                              "$ref": "#/components/schemas/RecognitionTeamAnniversary"
                      team_highlights:
                        type: object
                        description: Who stands out this period. Both rows are **null**
                          when the team has no recognition at all — render nothing
                          rather than a zero-count row (the web prints an em dash).
                        properties:
                          most_recognized:
                            "$ref": "#/components/schemas/RecognitionTeamHighlight"
                          most_recognition_given:
                            "$ref": "#/components/schemas/RecognitionTeamHighlight"
                      recent_team_recognition:
                        type: array
                        description: The merged award + shout-out activity stream
                          for this team, newest first, at most 5 rows.
                        items:
                          "$ref": "#/components/schemas/RecognitionTeamActivityRow"
                      team_members:
                        type: array
                        description: The roster the web renders as a table, each row
                          carrying that person's received / given / this-month counts.
                          The counts come from the same grouped queries the tiles
                          use, so the table costs no extra query. Always the viewer's
                          OWN reports — empty for an admin with nobody reporting to
                          them.
                        items:
                          "$ref": "#/components/schemas/RecognitionTeamMemberRow"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the caller is not a reviewer — no direct reports and not a Recognitions
            admin (error code `forbidden`), which is the API's answer to the web page's
            redirect.
  "/recognitions/team/members/{id}":
    get:
      tags:
      - Recognitions
      summary: One team member's recognition profile
      description: |
        The native-client mirror of the web **team member** screen — the page a
        row of the Team roster opens (`/recognition/manager/team_member/{id}`).
        Everything a manager can see about one person's recognition: five tiles
        and three lists.

        Every number and list is produced by the same query object that backs the
        web page (`Recognition::TeamMemberProfile`), so the two surfaces cannot
        drift on who may open a profile, what a tile counts, or which rows are
        safe to show the subject's manager.

        **Two gates, exactly like the web page.**

        1. The SURFACE is reviewer-only — a business admin, a Recognitions app
           admin, or anyone with direct reports (`User#recognition_reviewer?`).
           A plain employee gets **403 `forbidden`**. Check
           `viewer.is_reviewer` on `/recognitions/dashboard` before offering the
           Team tab at all.
        2. The MEMBER is then checked individually. A recognition admin reaches
           anyone in the tenant; a manager reaches only their own direct reports
           or someone deeper in their org subtree. Anyone else is **403
           `forbidden`** with the page's own wording.

        A user outside this tenant, or a nonexistent id, is **404** — indistinct
        from each other, so this endpoint can't be used to probe another tenant's
        user ids. Note that **nobody is on their own team**: a manager asking for
        their own id gets 403. `/recognitions/my_recognition` is that screen.

        **Counting rules**, applied identically to every tile and every list:

        * Recognition lives on two tables — formal **awards** and peer
          **shout-outs**. The two "This Month" tiles roll up BOTH, because the
          "This Month" column on the roster that links here does; the awards /
          shout-out split is published alongside each total so a client can
          render the sub-line without a second request.
        * **Revoked awards are excluded everywhere** — their store points have
          been clawed back, so they are gone from the leaderboard and the
          member's own record too.
        * **Private posts are excluded everywhere**, from the counts as well as
          the lists. The give form promises a private post is "Only you and the
          recipient", and this screen renders post bodies to the recipient's
          MANAGER — so counting one the list can never show would disclose that
          it exists.
        * Posts that are not active (pending or rejected moderation) are
          excluded: they are unpublished recognition.

        **Not paginated.** Each list returns the latest 20 rows, newest first,
        alongside the full `total_count` and a `has_more` flag — which is what
        the web prints as "Showing latest 20 of N". For deeper history use the
        browsable, paginated `/recognitions/feed`.

        All values that represent money are reported in **reward points**;
        storage is in dollars and the conversion happens server-side, so a client
        never needs the tenant's points-per-dollar rate.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The team member's user id — the `user.id` of a `team_members`
          row from `/recognitions/team`.
      responses:
        '200':
          description: The team member's recognition profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  team_member:
                    type: object
                    properties:
                      member:
                        "$ref": "#/components/schemas/RecognitionProfileSubject"
                      viewer:
                        type: object
                        description: The header's affordances, as the server will
                          actually answer them.
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin. An admin viewing somebody else's report is
                              not looking at "my team" — label the two differently.
                            example: false
                          can_revoke_awards:
                            type: boolean
                            description: 'Whether to offer **Revoke** on award rows.
                              Clawback is admin-only even though managers may open
                              this screen, so this is false for a manager. Uniform
                              across both award lists rather than per row: every row
                              here is an active award, and the control does not vary
                              by which one it points at. False too for a token that
                              does not hold the full `read:recognition` grant and
                              `destructive:recognition` — the scope `DELETE /recognitions/awards/{id}`
                              asks.'
                            example: false
                          can_give_recognition:
                            type: boolean
                            description: The "Recognize" button. The same guard the
                              give endpoint enforces, so a button whose POST would
                              be rejected is never offered.
                            example: true
                      features:
                        type: object
                        properties:
                          quick_award_enabled:
                            type: boolean
                            description: The "Quick Award" button — instant manager
                              award, no approval step. The tenant switch alone; this
                              endpoint is already reviewer-gated, which is the other
                              half of the write path's guard.
                            example: true
                      stats:
                        type: object
                        description: The five tiles.
                        properties:
                          awards_received:
                            type: integer
                            description: All-time active awards received.
                            example: 5
                          awards_given:
                            type: integer
                            description: All-time active awards this member gave.
                            example: 3
                          posts_received:
                            type: integer
                            description: All-time active, non-private shout-outs received
                              — the same set the `recognition_posts_received` list
                              draws from.
                            example: 15
                          this_month_received:
                            "$ref": "#/components/schemas/RecognitionProfileMonthTile"
                          this_month_given:
                            "$ref": "#/components/schemas/RecognitionProfileMonthTile"
                      awards_received:
                        allOf:
                        - "$ref": "#/components/schemas/RecognitionProfileSection"
                        description: Formal awards this member received, newest first.
                      recognition_posts_received:
                        allOf:
                        - "$ref": "#/components/schemas/RecognitionProfileSection"
                        description: Peer shout-outs this member received, newest
                          first. Private ones are never included.
                      awards_given:
                        allOf:
                        - "$ref": "#/components/schemas/RecognitionProfileSection"
                        description: Awards this member gave, newest first — each
                          row names the RECIPIENT.
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: 'One of three refusals, distinguished by `error.code`: the
            Recognitions app is not enabled for the tenant or this user is outside
            the app''s audience (`access_denied`); the caller is not a reviewer —
            no direct reports and not a Recognitions admin (`forbidden`); or the caller
            is a reviewer but this member is not on their team (`forbidden`, message
            "You can only view recognition for people on your team"), which is the
            API''s answer to the web page''s redirect.'
        '404':
          description: No such member in this tenant. Deliberately indistinguishable
            from a nonexistent id, so the endpoint can't be used to enumerate another
            tenant's users.
  "/recognitions/anniversary_roster":
    get:
      tags:
      - Recognitions
      summary: Anniversary Roster — the full work-anniversary list, filtered
      description: |
        The native-client mirror of the web **Anniversary Roster**, which is where
        the Team screen's "Upcoming anniversaries" card sends **View all**. Both
        surfaces read the same query object
        (`Recognition::AnniversaryRosterQuery`), so the filters, the reach and
        every tile are the same code on both.

        **This is the roster; `/recognitions/team` carries a teaser.** That
        endpoint's `upcoming_anniversaries` is a fixed 30-day window, five rows,
        no filters. This one takes the viewer's own date range and milestone-year
        filter, reports the four summary tiles, and pages.

        **Reviewer-only.** Gated on the canonical `User#recognition_reviewer?` —
        a business admin, a Recognitions app admin, or anyone with direct reports
        — exactly like the web page, which redirects everyone else. This endpoint
        answers **403 `forbidden`** rather than an empty roster, which a client
        would render as "nobody has an anniversary".

        **Reach follows the persona**, and `scope.reach` says which you got:

        * `workforce` — a recognition admin sees every active employee, because
          they own the milestone program.
        * `team` — anyone else sees their DIRECT reports. There is no
          "all reports" toggle here, because the web page has none.

        Only people with a hire date are listed, and only from their FIRST
        anniversary onward — a hire from last month has nothing to celebrate yet.
        A Feb 29 hire is observed on Feb 28 in non-leap years.

        **The tiles describe the FULL filtered roster, never the page** — a count
        that only covered page 1 would contradict the list beside it. `upcoming`
        therefore always equals `meta.total_count`.

        **Filters are refused, not coerced** — unlike `/recognitions/leaderboard`,
        where an unknown period sensibly falls back to `month`. There is no
        sensible fallback for a date: quietly serving the default window for
        `from_date=last-tuesday`, or an empty list for a backwards range, both
        read to the user as "nobody has an anniversary" when the truth is "your
        filter didn't arrive". Each of these is **400 `invalid_parameter`**:

        * a date that can't be parsed,
        * `to_date` before `from_date`,
        * a range wider than 365 days (past a year every calendar day is in the
          window, so the answer can't differ and the scan can't be narrowed),
        * a `milestone_years` with no readable year in it.

        Render `filters` rather than echoing what you sent: blank params resolve
        to the default window server-side.
      parameters:
      - name: from_date
        in: query
        required: false
        schema:
          type: string
          format: date
        description: Start of the window (inclusive). Defaults to **today**. `start_date`
          is accepted as an alias, so a roster URL copied out of the web app works
          unchanged.
      - name: to_date
        in: query
        required: false
        schema:
          type: string
          format: date
        description: End of the window (inclusive). Defaults to **today + 90 days**,
          the web page's own window. `end_date` is accepted as an alias. Must be on
          or after `from_date`, and no more than 365 days after it.
      - name: milestone_years
        in: query
        required: false
        schema:
          type: string
          example: '5,10,15'
        description: Comma-separated service-year counts — only people completing
          exactly these years are listed. Omit for every year. Whitespace and duplicates
          are tolerated; a value with no readable year in it is a 400 rather than
          a silent unfiltered list. `filters.milestone_year_options` carries the presets
          the web filter offers.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
        description: Defaults to 50, the web table's page size. Clamped to 100.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The Anniversary Roster screen
          content:
            application/json:
              schema:
                type: object
                properties:
                  anniversary_roster:
                    type: object
                    properties:
                      viewer:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          is_reviewer:
                            type: boolean
                            description: Always true in a 200 — the gate refuses everyone
                              else. Sent so the payload is self-describing.
                            example: true
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin. Widens the roster from this manager's reports
                              to the whole active workforce.
                            example: false
                          can_give_recognition:
                            type: boolean
                            example: true
                      features:
                        type: object
                        properties:
                          quick_award_enabled:
                            type: boolean
                            description: 'Instant manager award, no approval step.
                              Gates the Recognize / Celebrate button on every row
                              — when false, no row reports `can_recognize: true`.'
                            example: true
                      scope:
                        type: object
                        description: Whose anniversaries this roster covers.
                        properties:
                          reach:
                            type: string
                            enum:
                            - workforce
                            - team
                            description: "`workforce` for a recognition admin, `team`
                              (the viewer's direct reports) for anyone else. Label
                              the list accordingly — the same roster means different
                              things to the two personas."
                            example: team
                          label:
                            type: string
                            example: Your direct reports
                      filters:
                        type: object
                        description: What was actually applied, plus what the picker
                          should offer.
                        properties:
                          from_date:
                            type: string
                            format: date
                          to_date:
                            type: string
                            format: date
                          window_days:
                            type: integer
                            description: Days between from_date and to_date.
                            example: 90
                          max_window_days:
                            type: integer
                            description: The widest range this endpoint serves. Asking
                              for more is a 400.
                            example: 365
                          milestone_years:
                            type: array
                            nullable: true
                            description: The years actually filtered on, parsed and
                              sorted. **Null** means every year, which is the default.
                            items:
                              type: integer
                            example:
                            - 5
                            - 10
                          is_filtered:
                            type: boolean
                            description: Whether anything is narrowed — i.e. whether
                              a "Clear filters" affordance is worth showing. Matches
                              the web empty state's own condition.
                            example: false
                          defaults:
                            type: object
                            description: The window served when no dates are sent.
                            properties:
                              from_date:
                                type: string
                                format: date
                              to_date:
                                type: string
                                format: date
                              window_days:
                                type: integer
                                example: 90
                          milestone_year_options:
                            type: array
                            description: 'The presets the web filter''s select offers,
                              so a native picker renders the same choices instead
                              of maintaining a second list that can drift. `years:
                              null` is "All Years".'
                            items:
                              type: object
                              properties:
                                label:
                                  type: string
                                  example: Mid (5, 10, 15)
                                years:
                                  type: array
                                  nullable: true
                                  items:
                                    type: integer
                                  example:
                                  - 5
                                  - 10
                                  - 15
                      stats:
                        type: object
                        description: The web page's four tiles, in its order. Every
                          one describes the FULL filtered roster, never the page —
                          `upcoming` always equals `meta.total_count`.
                        properties:
                          upcoming:
                            type: integer
                            description: Everyone in the window, after filtering.
                            example: 24
                          this_month:
                            type: integer
                            description: How many fall in the CURRENT calendar month
                              — not the first month of the window, so a range starting
                              next March doesn't report March as "this month".
                            example: 6
                          milestone_years:
                            type: array
                            description: The distinct service-year counts present
                              in the roster, ascending — the LIST, not its size. ("4"
                              beside "Upcoming 120" read as "only 4 of them are milestones".)
                              Ships whole.
                            items:
                              type: integer
                            example:
                            - 1
                            - 3
                            - 5
                            - 10
                          milestone_years_display_limit:
                            type: integer
                            description: Where the web tile collapses the rest into
                              "+N more". Guidance, not a rule.
                            example: 8
                          recognized:
                            type: integer
                            description: How many already received their years-of-service
                              award this year.
                            example: 3
                      entries:
                        type: array
                        description: One page of the roster, soonest anniversary first.
                        items:
                          "$ref": "#/components/schemas/RecognitionAnniversaryRosterRow"
                      birthdays:
                        type: object
                        description: 'The web page''s "Upcoming Birthdays" card for
                          the same window and people: only people who celebrate their
                          birthday, and only where the caller may see date of birth.
                          The next `display_limit` are listed, with the true total
                          beside them. Month and day only, never a birth year or an
                          age.'
                        properties:
                          total:
                            type: integer
                            example: 7
                          display_limit:
                            type: integer
                            example: 25
                          entries:
                            type: array
                            items:
                              type: object
                              properties:
                                user:
                                  type: object
                                  description: The same person card the roster rows
                                    carry.
                                department:
                                  type: string
                                  example: Operations
                                occurs_on:
                                  type: string
                                  format: date
                                  description: This year's (or next year's) occurrence.
                                month:
                                  type: integer
                                  example: 10
                                day:
                                  type: integer
                                  example: 4
                                days_until:
                                  type: integer
                                  example: 8
                      meta:
                        type: object
                        description: Page envelope. Counts describe the whole filtered
                          roster.
                        properties:
                          current_page:
                            type: integer
                            example: 1
                          per_page:
                            type: integer
                            example: 50
                          total_count:
                            type: integer
                            example: 24
                          total_pages:
                            type: integer
                            example: 1
                          has_next_page:
                            type: boolean
                            example: false
                          has_prev_page:
                            type: boolean
                            example: false
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '400':
          description: 'A filter that couldn''t be read (error code `invalid_parameter`):
            an unparseable date, `to_date` before `from_date`, a range wider than
            365 days, or a `milestone_years` with no readable year. The message names
            the offending parameter — show it rather than a generic failure, since
            the fix is the user''s to make.'
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the caller is not a reviewer — no direct reports and not a Recognitions
            admin (error code `forbidden`), which is the API's answer to the web page's
            redirect.
  "/recognitions/award_cycles":
    get:
      tags:
      - Recognitions
      summary: Award Cycles — open now, opening soon and recent winners
      description: |
        The native-client mirror of the web **Award Cycles** page
        (`/recognition/award-cycles`) — the time-boxed award cycles a viewer can
        nominate into, plus what opens
        next and who recently won. Both surfaces read the same query object
        (`Recognition::AwardCyclesQuery`), so the section a cycle lands in, the
        committee review rule, the nominate gate and the winner names cannot
        drift between them.

        **Four lists, served whole.** There are no pagination parameters: the page
        is four fixed lists, and "Recent winners" is *capped* rather than paginated
        (`meta.recent_winners_limit`, currently 6 — the same depth the web page has
        always shown). A list's length IS its total.

        * `needs_my_review` — closed committee cycles this caller is asked to
          decide. Rendered ABOVE everything else, as the web page's amber
          "Needs your review" card.
        * `open_now` — cycles accepting nominations right now. The hero list, and
          the only one carrying a CTA.
        * `opening_soon` — scheduled cycles whose window has not started.
        * `recent_winners` — decided cycles, each with its winners (and their
          printable certificates) and the full honor roll.

        **Which list a cycle lands in is NOT its stored status.** It is the
        *effective* status, which reconciles the stored column against the live
        window: a cycle stored `scheduled` whose `opens_at` has passed is served
        under `open_now` with `status: "open"`, and one stored `open` whose
        `closes_at` has passed is served in neither browse list. Read the `status`
        the card reports, never re-derive it from the timestamps. Archived cycles
        are never served.

        **No role branching.** An employee, a manager and a recognition admin
        receive exactly the *same* `open_now`, `opening_soon` and
        `recent_winners`. Verified against the live page in the dev tenant: all
        four personas saw the identical three open, two upcoming and two decided
        cycles. Only two things differ per caller, and both delegate to the
        predicate the submit path enforces:

        * `needs_my_review` — the only per-viewer list. A caller is asked only if
          the cycle is a *closed committee* cycle, they are on its committee, and
          they are **not themselves nominated in it** — being up for an award
          recuses you from deciding it. An admin who is not on the committee is
          deliberately NOT nagged, mirroring the web list; `can_review` reports
          the wider gate (which does admit an admin) separately, so a client can
          still offer them the link.
        * `can_nominate` / `nomination_block_reason` — the window must still be
          live AND the caller must be allowed to give peer recognition at all.
          **Render `nomination_block_reason`** instead of a bare disabled button;
          a blocked card always carries one, and an offered card never does.

        **`nominated_by_me` is state, not a gate.** A cycle *pools* nominations, so
        a caller who has already nominated may nominate again. The flag exists so
        a client can say "you've nominated" rather than re-offering a fresh CTA
        with no memory.

        **A winner may have no certificate.** The decide step logs and continues
        when an Award can't be minted, so `winners[].certificate` is nullable.
        Handle the null rather than assuming a certificate is always there. When
        present it carries the printed certificate's fields in its own order —
        title, `awarded_by`, the italic `citation` quote, the program / points /
        company-value chips, then the footer's `unit` (the issuing organization)
        and date — plus `certificate_url` for the printable page.

        **`meta.empty_state` is present only when `open_now` is empty.** It carries
        the web page's own copy, including the conditional "check the Opening soon
        list below" clause, which appears only when `opening_soon` is non-empty.

        **Gated on Model B.** Award cycles are OFF by default. The web page
        redirects a tenant that hasn't opted in; this endpoint answers 403
        `feature_disabled` rather than serving three empty lists that would read as
        "your company runs no awards". Access is additionally gated the same way as
        every other endpoint in this namespace — the Recognitions app must be
        enabled for the tenant AND the caller must be inside the app's audience,
        otherwise 403 `access_denied`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The Award Cycles screen
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycles
                properties:
                  award_cycles:
                    type: object
                    required:
                    - viewer
                    - features
                    - needs_my_review
                    - open_now
                    - opening_soon
                    - recent_winners
                    - meta
                    properties:
                      viewer:
                        type: object
                        required:
                        - id
                        - name
                        - can_give_recognition
                        - is_recognition_admin
                        - has_cycles_to_review
                        properties:
                          id:
                            type: integer
                            example: 412
                          name:
                            type: string
                            example: Maya Chen
                          image:
                            type: string
                            nullable: true
                            description: Absolute avatar URL, or null when it can't
                              be resolved.
                          can_give_recognition:
                            type: boolean
                            description: Whether this tenant lets THIS caller give
                              peer recognition. When false, every `can_nominate` below
                              is false too — the same guard the submit path enforces.
                            example: true
                          is_recognition_admin:
                            type: boolean
                            description: Business admin-or-above, or a Recognitions
                              app admin.
                            example: false
                          has_cycles_to_review:
                            type: boolean
                            description: True when `needs_my_review` is non-empty
                              — the web page's amber review card in one boolean.
                            example: false
                      features:
                        type: object
                        description: The tenant toggles, so a client hides the same
                          surfaces the web nav hides.
                        required:
                        - award_cycles_enabled
                        - award_requests_enabled
                        properties:
                          award_cycles_enabled:
                            type: boolean
                            description: Model B. Always true in a 200 — the endpoint
                              403s otherwise. Present so one payload shape describes
                              both nomination models.
                            example: true
                          award_requests_enabled:
                            type: boolean
                            description: Model A (ad-hoc award requests). Defaults
                              on.
                            example: true
                      needs_my_review:
                        type: array
                        description: Closed committee cycles THIS caller is asked
                          to decide. The only per-viewer list. Empty for anyone who
                          is not on a committee, and for a committee member who is
                          themselves nominated in the cycle.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleReviewCard"
                      open_now:
                        type: array
                        description: Cycles accepting nominations right now, newest
                          deadline first. Empty → render `meta.empty_state`, not an
                          error.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleOpenCard"
                      opening_soon:
                        type: array
                        description: Scheduled cycles whose window has not started.
                          Never offers a CTA — the submit path would refuse.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleUpcomingCard"
                      recent_winners:
                        type: array
                        description: Decided cycles, most recently closed first, capped
                          at `meta.recent_winners_limit`.
                        items:
                          "$ref": "#/components/schemas/RecognitionAwardCycleDecidedCard"
                      meta:
                        type: object
                        required:
                        - counts
                        - recent_winners_limit
                        - closing_soon_days
                        - capabilities
                        properties:
                          counts:
                            type: object
                            description: The length of each list. Always agrees with
                              the arrays — every list is served whole.
                            properties:
                              open_now:
                                type: integer
                                example: 3
                              opening_soon:
                                type: integer
                                example: 2
                              recent_winners:
                                type: integer
                                example: 2
                              needs_my_review:
                                type: integer
                                example: 1
                          recent_winners_limit:
                            type: integer
                            description: The cap on `recent_winners`. Say "showing
                              the last N" rather than implying the tenant has only
                              ever run N cycles.
                            example: 6
                          closing_soon_days:
                            type: integer
                            description: The window `closing_soon` is computed against.
                              Advisory — it gates nothing.
                            example: 3
                          empty_state:
                            type: object
                            nullable: true
                            description: Present ONLY when `open_now` is empty. The
                              web page's own copy, so the native screen says what
                              the web screen says.
                            properties:
                              key:
                                type: string
                                example: open_now
                              title:
                                type: string
                                example: No awards open right now
                              message:
                                type: string
                                description: The "check the Opening soon list below"
                                  clause appears only when `opening_soon` is non-empty.
                                example: When an award cycle opens for nominations,
                                  it will show up here — check the "Opening soon"
                                  list below.
                          capabilities:
                            type: object
                            properties:
                              can_give_recognition:
                                type: boolean
                                example: true
                              award_cycles_enabled:
                                type: boolean
                                example: true
                              award_requests_enabled:
                                type: boolean
                                example: true
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the tenant has not turned award cycles on (error code `feature_disabled`),
            which is the API's answer to the web page's redirect.
  "/recognitions/award_cycles/{id}/results":
    get:
      tags:
      - Recognitions
      summary: Award Results — one decided cycle's winners, certificates and honor
        roll
      description: |
        The native-client mirror of the web **Award Results** page
        (`/recognition/award-cycles/{id}/results`) — the screen a "Recent winners"
        row opens: the trophy hero with the cycle name and announce date, one card
        per winner with their printable certificate, and the honor roll thanking
        everyone who was nominated.

        **The body IS a `recent_winners` card.** `award_cycle_results` is the same
        `RecognitionAwardCycleDecidedCard` that
        `GET /recognitions/award_cycles` serves under `recent_winners`, plus a
        `meta` block carrying the results page's copy. Both are produced by one
        serializer, so a client that already holds the row can render this screen
        from the same model and refresh it from here — and the two can never name
        a different winner, a different certificate or a different honor roll.

        **Why the endpoint exists at all.** The list *caps* "Recent winners" at
        `meta.recent_winners_limit` (6) and never pages past it. Results for the
        seventh cycle back — reached from a notification, a shared certificate or
        a search hit — appear in no list response, and the web page has always been
        able to show them. This is that lookup.

        **A winner may have no certificate.** The decide step logs and continues
        when an Award can't be minted, so `winners[].certificate` is nullable.
        Handle the null rather than assuming a certificate is always there.

        **`winners` may be empty while `honor_roll` is not.** Nobody clearing a
        Spotlight cycle's threshold is a real outcome, not an error — people were
        nominated, none met the bar. `meta.empty_state` carries the web page's own
        line for it, and the honor roll still stands.

        **Nothing viewer-wide is served here.** There is no `viewer` block and no
        `needs_my_review`: this endpoint's query object is narrowed to ONE cycle,
        so any capability computed from it would report "nothing to review" while
        other cycles wait on the caller. Read those from
        `GET /recognitions/award_cycles`.

        **No role branching.** Results are a company-wide celebration — the web
        action has no role check — so an employee, a manager and a recognition
        admin receive byte-identical payloads.

        **Gated on Model B**, exactly like the list: award cycles are OFF by
        default, and a tenant that hasn't opted in gets 403 `feature_disabled`
        rather than a deep link that works behind a hidden tab. Access is
        additionally gated the same way as every other endpoint in this namespace
        — the Recognitions app must be enabled for the tenant AND the caller must
        be inside the app's audience, otherwise 403 `access_denied`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle's id — the `id` on any `recent_winners` card.
          Scoped to the caller's tenant; another tenant's cycle 404s, indistinct from
          a missing one.
        example: 11
      responses:
        '200':
          description: The Award Results screen for one decided cycle
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_results
                properties:
                  award_cycle_results:
                    allOf:
                    - "$ref": "#/components/schemas/RecognitionAwardCycleDecidedCard"
                    - type: object
                      required:
                      - meta
                      properties:
                        meta:
                          "$ref": "#/components/schemas/RecognitionAwardCycleResultsMeta"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not enabled for the tenant /
            this user is outside the app's audience (error code `access_denied`),
            or the tenant has not turned award cycles on (error code `feature_disabled`).
        '404':
          description: No such cycle in this tenant. Another tenant's cycle answers
            the same way as a missing one — the endpoint is enumeration-safe.
        '409':
          description: |
            The cycle exists but has **not been decided yet** (error code
            `results_unavailable`) — the API's answer to the web page's "Results
            aren't available yet." redirect. Deliberately not a 404: the cycle is
            real and will have results, so say "not announced yet", never "gone".

            Covers every non-decided state, including **archived** — `status` is
            one column, so archiving replaces "decided" and the web page redirects
            an archived cycle too.

            `error.details` carries `cycle_id` and the cycle's effective `status`
            (`scheduled` / `open` / `closed` / `archived`), so a client can say
            *why* without a second request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: results_unavailable
                      message:
                        type: string
                        example: Results aren't available yet.
                      details:
                        type: object
                        properties:
                          cycle_id:
                            type: integer
                            example: 10
                          status:
                            type: string
                            enum:
                            - scheduled
                            - open
                            - closed
                            - archived
                            example: closed
  "/recognitions/award_cycles/{id}/review":
    get:
      tags:
      - Recognitions
      summary: Committee review — the anonymized nominee pool for one closed cycle
      description: |
        The native-client mirror of the web **Committee review** page
        (`/recognition/award-cycles/{id}/review`) — the screen a "Needs your
        review" card opens: the anonymized pool of everyone nominated in a closed
        committee cycle, what colleagues wrote about them, who else is on the
        committee, and this member's own saved ballot.

        Both surfaces run the same query object (`Recognition::CycleReviewQuery`),
        so the gate, the pool, its order and the justifications shown cannot drift
        between them. The **write** — `POST /recognitions/award_cycles/{id}/picks`
        — runs the same gate, so a POST can never be accepted on a cycle whose GET
        would have refused.

        **The body opens with a `needs_my_review` card.** Every field of
        `RecognitionAwardCycleReviewCard` is here, produced by the same serializer
        `GET /recognitions/award_cycles` uses, so a client that arrived from that
        list renders this screen from one model and the two can never disagree on
        the deadline, the prize or the pick count.

        **Anonymity is the product, not a formatting choice.** `justifications`
        carry text and an opaque id — no nominator, and nothing that can be joined
        back to one. The running committee signal (how many *other* members picked
        each nominee) is absent for the same reason the web page omits it: a member
        who can see the tally is being anchored rather than asked.

        **The pool is served whole and is NOT paginated** — deliberately, and for
        the same reason the web page isn't: a submit replaces the member's entire
        ballot, so a member deciding from a second page would wipe the first page's
        picks. `pool_count` is a total, not a page size.

        **Ordering is deterministic**: tally descending, then name, then id. Two
        identical requests deal the same rows, quoting the same colleagues in the
        same order.

        **`justifications` is capped** at `meta.justification_preview_limit` (4).
        `additional_justification_count` is the remainder — render "…and N more"
        rather than implying you have them all.

        **The caller's own row can never be picked.** It is flagged `is_me` with
        `can_pick: false`, and the submit path strips the id even if it is sent.
        This holds for an admin who was let past the recusal gate too.

        **An empty pool is a 200, not an error** — a committee cycle nobody
        nominated into is a real state, and `meta.empty_state` carries the web
        page's own line for it.

        **Gated on Model B**, exactly like the list: award cycles are OFF by
        default, and a tenant that hasn't opted in gets 403 `feature_disabled`.
        Access is additionally gated the same way as every other endpoint in this
        namespace — the Recognitions app must be enabled for the tenant AND the
        caller must be inside the app's audience, otherwise 403 `access_denied`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle's id — the `id` on any `needs_my_review` card.
          Scoped to the caller's tenant; another tenant's cycle 404s, indistinct from
          a missing one.
        example: 10
      responses:
        '200':
          description: The committee review screen for one closed committee cycle
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_review
                properties:
                  award_cycle_review:
                    "$ref": "#/components/schemas/RecognitionAwardCycleReview"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            One of four refusals, distinguished by `error.code`:

            * `access_denied` — the Recognitions app is not enabled for the
              tenant, or this user is outside the app's audience.
            * `feature_disabled` — the tenant has not turned award cycles on.
            * `not_committee_member` — the caller is neither on this cycle's
              committee nor a business admin.
            * `recused` — the caller is a committee member who is themselves
              nominated in this cycle. Being up for an award recuses you from
              deciding it. (A business admin is admitted anyway, matching the web,
              but still cannot pick themselves.)

            The last two carry `error.details` with `cycle_id` and the cycle's
            effective `status`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
        '404':
          description: No such cycle in this tenant. Another tenant's cycle answers
            the same way as a missing one — the endpoint is enumeration-safe.
        '409':
          description: |
            The cycle exists but there is nothing to review, by `error.code`:

            * `not_committee_cycle` — a **Spotlight** cycle. Nobody reviews it;
              nominees clear a threshold instead.
            * `cycle_not_closed` — nominations are **still open** (the pool would
              be partial), or the admin has **already decided** the cycle (the
              review is inert). Both are the web page's "This cycle isn't open for
              committee review." redirect.

            Deliberately not a 404: the cycle is real and the caller may well be
            entitled — this just isn't the moment. `error.details` carries
            `cycle_id` and the effective `status` so a client can say *why*
            without a second request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
  "/recognitions/award_cycles/{id}/nominations/new":
    get:
      tags:
      - Recognitions
      summary: Nominate composer — the form for putting a colleague forward
      description: |
        The native-client mirror of the web **Nominate** page
        (`/recognition/award-cycles/{id}/nominate`), the screen the Award Cycles
        list's **Nominate** CTA opens.

        `cycle` is the **same `open_now` card** `GET /recognitions/award_cycles`
        serves, so a client that arrived from that list renders this screen from
        one model and the two can never disagree on the deadline, the prize, the
        criteria, or whether this viewer may nominate. It carries `can_nominate`
        and — when that is false — `nomination_block_reason`, the sentence to show
        instead of a dead disabled button.

        **It ships no nominee roster.** The `nominee_id` typeahead is
        `GET /recognitions/employee_suggestions`, named in
        `form.nominee_search_url`, so this screen does not carry a second copy of
        the tenant's directory. It also does not list who else has been nominated:
        the pool is anonymous until the cycle is decided, and a nominator who
        could see it would be anchored — the same reasoning the committee review
        screen records for omitting its running tally.

        **The GET is not gated on the window.** A scheduled or closed cycle still
        renders, with `can_nominate: false` and the reason — matching the web page,
        and giving a client something to draw. Only the POST refuses (409).

        **Givers only.** Nominating is a *give*, so both verbs are gated on
        `Recognition::GivingAccess.can_give?` — the tenant's peer-recognition
        switch, plus managers and admins always. That is the same predicate behind
        `can_nominate` on every cycle card and behind the web page's own
        `enforce_peer_recognition!` bounce, so a client is never offered a form
        whose POST would be refused.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle being nominated into. Scoped to the caller's
          tenant.
        example: 12
      responses:
        '200':
          description: The cycle, the form and the page's copy
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_nomination_form
                properties:
                  award_cycle_nomination_form:
                    "$ref": "#/components/schemas/RecognitionCycleNominationForm"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied` (Recognitions app off, or the caller is outside
            its audience), `feature_disabled` (the tenant hasn't turned award cycles
            on) or `giving_not_allowed` (this caller may not give recognition, so
            may not nominate). The giving gate runs BEFORE the cycle is looked up,
            so a caller without permission cannot use this endpoint to discover which
            cycle ids exist."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
        '404':
          description: No such cycle in this tenant.
  "/recognitions/award_cycles/{id}/nominations":
    post:
      tags:
      - Recognitions
      summary: Nominate a colleague for an award cycle
      description: |
        The native-client mirror of the web **Submit nomination** button
        (`POST /recognition/award-cycles/{id}/nominate`). Both surfaces run the
        same service (`Recognition::CycleNominationSubmission`), so the giving
        gate, the window check, the nominee rule, the pooled status, the default
        title and the confirmation copy are the same code.

        **This is not `POST /recognitions/nominations`.** That endpoint files a
        Model A ad-hoc award *request*, which routes to a reviewer and can mint an
        Award on the spot. A cycle nomination is **pooled**: it is born `pending`
        and stays there until the cycle closes and is decided — by the Spotlight
        `threshold_nominators` count, or by a committee ballot through
        `POST /recognitions/award_cycles/{id}/picks`. There is no reviewer on this
        path and no per-nomination approve/reject, which is why the response
        carries `pooled: true` and no `requires_approval`.

        **The program comes from the cycle.** `recognition_program_id` is not
        accepted: a cycle nomination belongs to the program running the cycle, and
        sending one is ignored rather than honoured.

        **Nominate again, yes; the same person twice, no.** A cycle pools
        nominations, so one nominator may put several *different* colleagues
        forward — `meta.allows_multiple_nominations` on the composer says so, and
        a client must not hide the CTA after one submit. The model's duplicate
        guard is keyed on (nominator, nominee, cycle), so a second nomination of
        the *same* person is refused with 422 `validation_failed`. That is exactly
        what makes the Spotlight threshold mean "N **distinct** colleagues".

        **Send an `Idempotency-Key`.** The retry is replayed *before* anything is
        written, so a client that times out and retries gets its original 201 back
        rather than tripping the duplicate guard and being told it had already
        nominated someone it does not think it nominated.

        The `nomination` wrapper is optional — the fields may be sent flat, and
        the web form's `nomination[...]` shape is accepted unchanged.

        Gated identically to the composer above: same app access, same Model B
        toggle, same giving permission, same tenant scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle being nominated into. Scoped to the caller's
          tenant.
        example: 12
      - name: Idempotency-Key
        in: header
        required: false
        description: Retry-safety. A repeat with the same key replays the original
          201 and writes nothing — checked before the write, not after.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionCycleNominationRequest"
            examples:
              minimal:
                summary: The two fields the form actually asks for
                value:
                  nomination:
                    nominee_id: 893
                    description: Rebuilt the onboarding checklist and cut new-hire
                      ramp by a week.
              flat:
                summary: The same body without the wrapper
                value:
                  nominee_id: 893
                  description: Rebuilt the onboarding checklist and cut new-hire ramp
                    by a week.
              with_title:
                summary: Overriding the title (defaults to the cycle's name)
                value:
                  nomination:
                    nominee_id: 893
                    title: The onboarding rebuild
                    description: Rebuilt the onboarding checklist and cut new-hire
                      ramp by a week.
      responses:
        '201':
          description: The nomination as filed, plus the cycle card with it counted
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_nomination
                properties:
                  award_cycle_nomination:
                    "$ref": "#/components/schemas/RecognitionCycleNominationResult"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied`, `feature_disabled` or `giving_not_allowed`
            — the same three refusals, with the same wording, as the composer above.
            A rule that guarded only the form is a rule a direct POST walks past."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
        '404':
          description: No such cycle in this tenant.
        '409':
          description: "`cycle_not_open` — the cycle is real and the caller is entitled,
            but the nomination window isn't live: scheduled, closed, decided, archived,
            or force-closed by an admin while `closes_at` is still in the future.
            The state is read through the cycle's EFFECTIVE status, so the stored
            `status` column is not the answer, and `error.details.status` carries
            the reconciled value a client should re-render from."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
        '422':
          description: |
            One of three, and nothing is written in any of them:

            * `invalid_nominee` — no `nominee_id` was sent, or the id is not an
              active member of this tenant.
            * `validation_failed` — the model refused: a self-nomination, a second
              nomination of the same person in this cycle, or a reason outside
              10–1000 characters.
            * `content_rejected` — the tenant's content-moderation keyword screen
              refused the submitted words. This is not cosmetic here: the
              `description` becomes the cycle's org-wide "kind words", which are
              emailed to the winners **and** to every non-winner who was
              nominated, so unscreened text would be mailed to the person it was
              written about. Only what the caller SUBMITTED is screened — a title
              defaulted from the cycle's own name never is.

            `validation_failed` and `content_rejected` both carry
            `error.details.field_errors`, so a native form can mark the offending
            field instead of parsing the sentence.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleNominationRefusal"
  "/recognitions/award_cycles/{id}/picks":
    post:
      tags:
      - Recognitions
      summary: Save a committee ballot — one nominee or several
      description: |
        The native-client mirror of the web review page's **Save my
        recommendations** button (`POST /recognition/award-cycles/{id}/review`).
        Both surfaces run the same service (`Recognition::CyclePickSubmission`),
        so the gate, the pool filter, the never-vote-for-yourself rule and the
        replace semantics are the same code.

        **Single or multiple, one call.** Send `nominee_ids` (an array, or a
        comma-separated string) or `nominee_id` (one scalar) — a member picking one
        person and a member picking six use the same request.

        **It REPLACES the ballot; it does not merge.** A submit is the member's
        complete set of recommendations for this cycle: their existing picks are
        deleted and re-created from what was sent, so sending one id makes that id
        the *only* pick. This is why the review screen is not paginated — send the
        whole selection, never a delta.

        **An explicit empty selection clears the ballot** (`{"nominee_ids": []}`),
        which is how a member withdraws. **Omitting both keys is refused** with 422
        `no_selection` rather than read as "clear everything" — the one place this
        endpoint is stricter than the web form, which always posts its checkbox set.

        **Ids that cannot be picked are dropped, not fatal.** Anyone not in this
        cycle's nominee pool, and the caller's own id, are removed and listed in
        `ignored_ids`. The web renders no checkbox for either; a non-empty
        `ignored_ids` means the client's list is stale.

        **Repeated ids are de-duplicated** — a nominee is picked once or not at all.

        **`changed` reports whether anything actually moved**, so a client can skip
        a needless "saved" toast on a no-op re-submit.

        Nothing here touches another committee member's ballot, and the running
        tally is never returned — read the outcome from
        `GET /recognitions/award_cycles/{id}/review`, and the decided winners from
        `GET /recognitions/award_cycles/{id}/results` once an admin has decided.

        Gated identically to the GET above — same app access, same Model B toggle,
        same four-rule review gate, same status codes.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The award cycle being decided. Scoped to the caller's tenant.
        example: 10
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionCyclePicksRequest"
            examples:
              multiple:
                summary: Several nominees (the usual case)
                value:
                  nominee_ids:
                  - 893
                  - 265
              single:
                summary: One nominee, via the scalar shorthand
                value:
                  nominee_id: 893
              comma_separated:
                summary: The web's hidden-field form
                value:
                  nominee_ids: '893,265'
              clear:
                summary: Withdraw — clear this member's ballot
                value:
                  nominee_ids: []
      responses:
        '200':
          description: The ballot after the write
          content:
            application/json:
              schema:
                type: object
                required:
                - award_cycle_picks
                properties:
                  award_cycle_picks:
                    "$ref": "#/components/schemas/RecognitionCyclePicksResult"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`access_denied`, `feature_disabled`, `not_committee_member`
            or `recused` — the same four refusals, with the same wording, as the GET
            above. A rule that guarded only the read would be a rule a direct POST
            walked past."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
        '404':
          description: No such cycle in this tenant.
        '409':
          description: "`not_committee_cycle` or `cycle_not_closed` — as the GET above.
            Also returned when the cycle changes state *between* a client's read and
            its write (an admin decided it mid-ballot); nothing is saved."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionCycleReviewRefusal"
        '422':
          description: |
            Neither `nominee_ids` nor `nominee_id` was sent (error code
            `no_selection`). The saved ballot is left untouched. To clear it,
            send an explicit empty array.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: no_selection
                      message:
                        type: string
                        example: Send nominee_ids (or nominee_id). Pass an empty nominee_ids
                          to clear your recommendations.
  "/recognitions/approvals":
    get:
      tags:
      - Recognitions
      summary: Pending Approvals queue — nominations + posts awaiting review
      description: |
        The native-client mirror of the web **Pending Approvals** page, reached
        from Team ▸ Pending Approvals ▸ **View all**. Both surfaces read the same
        query object (`Recognition::ApprovalsQueue`), so the two cannot drift on
        which rows a reviewer sees, in what order, or how the filters behave.

        **Two lists**, exactly as the web page stacks them:

        * `nominations` — the ad-hoc award requests awaiting this reviewer's
          decision (cycle nominations are excluded; those are decided through the
          cycle, never one at a time).
        * `posts` — peer recognition held for this reviewer's **manager
          approval**, still unpublished until a reviewer decides.

        Each list paginates **independently** (`page` for nominations,
        `posts_page` for posts) because a reviewer can be deep in one while the
        other is short; a single shared cursor would strand rows.

        **Reviewer-only, and the reviewable rule is emergent.** The endpoint is
        gated on the canonical `User#recognition_reviewer?` (a business admin, a
        Recognitions app admin, or a manager with direct reports) and refuses
        everyone else with **403 `forbidden`** rather than an empty queue that
        would read as "nothing to approve". WITHIN that gate the two personas see
        different sets, decided by data not by a role flag: an **admin** sees the
        whole tenant's queue; a **manager** sees nominations assigned to them,
        unassigned (available for any approver), or nominating one of their own
        reports, plus the posts routed to them. `viewer.is_recognition_admin`
        tells the client which it is.

        This endpoint is **read-only**. Acting on the queue is the sibling
        endpoints' job: the bulk approve/reject actions and the per-nomination
        approve/reject routes. AI-flagged content is NOT part of this queue — it
        is reviewed in the separate unified Content Moderation Queue.

        Values that represent money are reported in **reward points**; storage is
        in dollars and the conversion happens server-side.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Case-insensitive search. Matches the nominee/recipient name and
          the nomination title / post content across BOTH lists.
        schema:
          type: string
      - name: program_id
        in: query
        required: false
        description: Narrow the NOMINATIONS list to one recognition program. Posts
          have no program in this queue and are unaffected. The programs actually
          present in the queue are returned in `filters.program_options` (derived
          from the UNFILTERED queue, so switching never requires clearing the filter
          first).
        schema:
          type: integer
      - name: overdue
        in: query
        required: false
        description: When truthy, keep only NOMINATIONS submitted before the tenant's
          reminder window (the `nomination_reminder_days` setting, clamped to 1..60,
          default 7 days) — the "waiting longest" filter.
        schema:
          type: boolean
      - name: page
        in: query
        required: false
        description: 1-based page number for the NOMINATIONS list. Values below 1
          are treated as 1.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: posts_page
        in: query
        required: false
        description: 1-based page number for the POSTS list (independent of `page`).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, applied to both lists. Clamped to 50.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: The reviewer's pending-approvals queue.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approvals:
                    type: object
                    properties:
                      viewer:
                        type: object
                        properties:
                          id:
                            type: integer
                          name:
                            type: string
                          image:
                            type: string
                            nullable: true
                          is_recognition_admin:
                            type: boolean
                            description: True when this reviewer sees the WHOLE tenant's
                              queue; false for a manager scoped to their own team.
                          can_give_recognition:
                            type: boolean
                      capabilities:
                        type: object
                        description: Viewer-level toggles the client uses to render
                          the queue's global actions.
                        properties:
                          quick_award_enabled:
                            type: boolean
                          award_requests_enabled:
                            type: boolean
                          award_cycles_enabled:
                            type: boolean
                      filters:
                        type: object
                        properties:
                          applied:
                            type: object
                            description: The filter values the server actually applied
                              (echoed back for the chips).
                            properties:
                              q:
                                type: string
                                nullable: true
                              program_id:
                                type: integer
                                nullable: true
                              overdue:
                                type: boolean
                          program_options:
                            type: array
                            description: The programs present in the UNFILTERED queue
                              — the "Program" dropdown's options.
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                      nominations:
                        type: object
                        properties:
                          items:
                            type: array
                            items:
                              "$ref": "#/components/schemas/RecognitionApprovalNomination"
                          meta:
                            "$ref": "#/components/schemas/RecognitionApprovalsPageMeta"
                      posts:
                        type: object
                        properties:
                          items:
                            type: array
                            items:
                              "$ref": "#/components/schemas/RecognitionApprovalPost"
                          meta:
                            "$ref": "#/components/schemas/RecognitionApprovalsPageMeta"
                      total_pending:
                        type: integer
                        description: The whole queue this reviewer must clear — nominations
                          + post approval requests (a group give's posts for one approver
                          count once) across every page. The badge figure, matching
                          the Team endpoint's `pending_approvals.total`.
                        example: 7
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not accessible to the caller (error
            code `access_denied`), or the caller is not a reviewer (error code `forbidden`)
            — the API's answer to the web page's redirect.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/nominations":
    post:
      tags:
      - Recognitions
      summary: File a nomination (nominate someone for an award)
      description: |
        Nominate a colleague for an award — the native-client mirror of the web
        "Nominate Someone" form (RecognitionController#create_nomination), the
        composer behind the feed's **+ ▸ Nominate**. Both surfaces run ONE service
        (`Recognition::NominationSubmission`), so the eligibility gates, the
        approval routing, the reviewer assignment and the confirmation copy are
        literally the same code.

        **Who may call it.** This is the NOMINATOR half of the nominations
        controller — unlike `/approve` and `/reject`, it is NOT reviewer-gated.
        The caller needs three things, each answered up front by
        `GET /recognitions/config`:

        * the Recognitions app is accessible to them
        * award requests (Model A) are on for the tenant —
          `config.features.award_requests_enabled`
        * they may give recognition at all — `config.permissions.can_give`
          (a tenant that switched peer recognition off leaves this to managers
          and Recognition admins)

        **Build the form from `GET /recognitions/config`.** Its `programs` block
        is the program picker, with `can_nominate` and `nomination_block_reason`
        per program (hide the ones the caller can't use rather than disabling
        them) and each program's `categories`, each carrying `min_points` /
        `max_points`. `limits` states every bound this endpoint enforces, and
        `economy.points_per_dollar` is the conversion rate. The nominee picker is
        `GET /recognitions/employee_suggestions`.

        **Reward points vs stored dollars.** `requested_value` is supplied in
        reward POINTS (what the nominator types), exactly like the web form. The
        record stores dollars (1 pt = 1¢ at the default rate) and the server
        converts for you — do NOT pre-divide.

        **Approval routing.** With `require_manager_approval` off (the default)
        the nomination is filed already `approved` — nobody has to decide it, and
        it never appears in a reviewer's queue. With it on, the nomination is
        filed `under_review` and routed to the nominee's own manager (falling back
        to the tenant's first active admin), unless the tenant's autonomous
        approval ceiling clears it. Read `requires_approval` and
        `autonomously_approved` on the response rather than inferring from
        `status`.

        **Retries.** Send an `Idempotency-Key` header: a retry after a timeout
        replays the original `201` instead of filing a second nomination. Without
        one, the model's duplicate guard is the backstop — a second live
        nomination of the same person for the same award comes back `422`.

        **Body shape.** Both the web-shaped `{ "nomination": { … } }` and a flat
        `{ … }` body are accepted. Use `multipart/form-data` when attaching
        supporting evidence.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                nomination:
                  "$ref": "#/components/schemas/RecognitionNominationInput"
              required:
              - nomination
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/RecognitionNominationInput"
              - type: object
                properties:
                  supporting_files:
                    type: array
                    description: Optional evidence the approver sees beside the justification
                      — a metrics export, a customer email, a before/after screenshot.
                      Screened server-side by content type (sniffed from the bytes,
                      not trusted from the client), size and count; the exact rules
                      are reported by `config.limits.nomination_supporting_file_*`.
                    items:
                      type: string
                      format: binary
      responses:
        '201':
          description: The nomination was filed.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                - requires_approval
                properties:
                  message:
                    type: string
                    description: A human-readable outcome, mirroring the web flash
                      verbatim.
                    example: Nomination for Ada Lovelace has been submitted successfully!
                  requires_approval:
                    type: boolean
                    description: True when a reviewer still has to decide (`status`
                      is `under_review`). False means the award is already granted.
                    example: false
                  autonomously_approved:
                    type: boolean
                    description: True when the tenant DOES require manager approval
                      but this request came in under the autonomous approval ceiling
                      and was cleared without a reviewer (an Automation Hub audit
                      row is written). Lets a client explain why nobody was asked.
                    example: false
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            One of five refusals, distinguished by `error.code`:

            * `access_denied` — the Recognitions app is not accessible to the caller
            * `feature_disabled` — award requests (Model A) are off for the tenant
            * `giving_not_allowed` — the tenant limits giving to managers and
              Recognition admins, and the caller is neither
            * `program_unavailable` — the program is not open for nominations right
              now (inactive, outside its window, a lifecycle/milestone program, or
              not in this business). The picker never lists these.
            * `not_eligible` — the program IS open, just not to this nominator (a
              manager-only program, or achievement criteria they don't meet).
              `config.programs[].nomination_block_reason` says so in their words.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: The nominee is not a member of the caller's business (error
            code `nominee_not_found`) — indistinct from a user id that doesn't exist,
            so this endpoint can't be used to enumerate the directory.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`nominee_id` was not sent (error code `invalid_nominee`) —
            a malformed request, never answered as an unknown person. Otherwise the
            nomination failed validation (error code `validation_failed`) — `error.details.fields`
            carries the per-field messages so a client can mark the offending inputs.
            Covers self-nomination, a duplicate live nomination of the same person
            for the same award, the title and description bounds, a requested value
            outside the category's range or over the program's per-award cap, and
            rejected supporting files. A `nomination_failed` code here means the submit
            raised unexpectedly."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: validation_failed
                      message:
                        type: string
                        example: Description is too short (minimum is 10 characters)
                      details:
                        type: object
                        properties:
                          fields:
                            type: object
                            description: Per-field validation messages, keyed by attribute.
                            additionalProperties:
                              type: array
                              items:
                                type: string
                            example:
                              description:
                              - is too short (minimum is 10 characters)
        '429':
          description: The nominate rate limit (error code `rate_limited`) — 10 per
            hour per person per tenant, the SAME counter the web form is behind, so
            a client can't out-run it by switching surface. `Retry-After` and `error.details.retry_after_seconds`
            carry the window.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/nominations/bulk_approve":
    post:
      tags:
      - Recognitions
      summary: Bulk-approve recognition requests (nominations)
      description: |
        Approve MANY award-request nominations in one call — the native-client
        mirror of the web pending-approvals "Approve selected" action
        (RecognitionController#bulk_approve_nominations). Both surfaces run the
        SAME service (Recognition::BulkNominationReview), so they can't drift on
        who may bulk-approve what, on the partial-success behaviour, or on how a
        mixed result is reported.

        **Partial success, not all-or-nothing.** Each nomination is approved
        independently through Nomination#record_approval! (so a multi-level
        program advances a request to the next approver rather than minting the
        award early). One failing row does not roll back the others. The 200 body
        breaks the batch down:

        * `processed` — the nominations that were approved, each with its
          resulting `status` and (for a multi-level chain) the step it advanced to.
        * `skipped_ids` — ids that were NOT acted on because they are not
          reviewable by the caller, already decided, or belong to another tenant.
          Never silently approved.
        * `errors` — per-row failures, each naming the nominee and the reason.

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or a manager with direct reports. The
        reviewable rule then narrows the batch to the nominations this caller may
        actually decide; anything outside it comes back in `skipped_ids`.

        Up to 200 ids per call.

        Delete-level (owner decision 2026-10-04): requires the `destructive:recognition`
        scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - nomination_ids
              properties:
                nomination_ids:
                  description: The nominations to approve. An array of ids (preferred)
                    or a comma-separated string. Blanks/zeros are dropped and duplicates
                    collapsed. 1..200 ids.
                  oneOf:
                  - type: array
                    items:
                      type: integer
                  - type: string
                  example:
                  - 8123
                  - 8124
                  - 8130
                notes:
                  type: string
                  nullable: true
                  description: Optional approval note recorded on each resulting award
                    (`reviewer_notes`).
                  example: Q3 spot-award batch — all verified with the managers.
      responses:
        '200':
          description: The batch was accepted and applied. Read `processed_count`,
            `skipped_count` and `error_count` — a 200 can still carry skipped or errored
            rows.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionBulkReviewResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not accessible to the caller (error
            code `access_denied`), or the caller is not a reviewer (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: 'The request itself was rejected before any nomination was
            touched: no ids selected (error code `no_ids`) or more than 200 ids (error
            code `too_many`).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/bulk_reject":
    post:
      tags:
      - Recognitions
      summary: Bulk-reject recognition requests (nominations)
      description: |
        Reject MANY award-request nominations in one call — the native-client
        mirror of the web pending-approvals "Reject selected" action
        (RecognitionController#bulk_reject_nominations). Both surfaces run the
        SAME service (Recognition::BulkNominationReview).

        A **reason is required** — exactly as the web modal requires it — and is
        recorded on every rejected nomination (`reviewer_notes`) and shown to each
        nominator. A blank/whitespace reason is refused with 422
        `reason_required` and nothing is touched.

        **Partial success**, reported the same way as bulk approve: `processed`
        (the rejected nominations), `skipped_ids` (not reviewable / already
        decided / another tenant's), and `errors` (per-row failures).

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or a manager with direct reports. The
        reviewable rule narrows the batch; anything outside it comes back in
        `skipped_ids`.

        Up to 200 ids per call.

        Delete-level (owner decision 2026-10-04): requires the `destructive:recognition`
        scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - nomination_ids
              - reason
              properties:
                nomination_ids:
                  description: The nominations to reject. An array of ids (preferred)
                    or a comma-separated string. Blanks/zeros are dropped and duplicates
                    collapsed. 1..200 ids.
                  oneOf:
                  - type: array
                    items:
                      type: integer
                  - type: string
                  example:
                  - 8140
                  - 8141
                reason:
                  type: string
                  description: The reason for rejecting, recorded on every nomination
                    (`reviewer_notes`) and shown to each nominator. Required and non-blank.
                  example: These duplicate awards already granted for the same work.
      responses:
        '200':
          description: The batch was accepted and applied. Read the counts — a 200
            can still carry skipped or errored rows.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionBulkReviewResult"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not accessible to the caller (error
            code `access_denied`), or the caller is not a reviewer (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: 'The request itself was rejected before any nomination was
            touched: the reason is missing/blank (error code `reason_required`), no
            ids selected (error code `no_ids`), or more than 200 ids (error code `too_many`).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/{id}/approve":
    post:
      tags:
      - Recognitions
      summary: Approve a recognition request (nomination)
      description: |
        Approve an award-request nomination — the native-client mirror of the web
        approval action (RecognitionController#approve_nomination). Runs the SAME
        collaborators the web page runs, so the two surfaces cannot drift: the
        per-record ReviewableNominations authorization rule, the points→dollars
        conversion, and Nomination#record_approval!.

        On the **final** approval level this mints the Award (moving the
        recognition from "pending approval" to a real, points-bearing award) and
        notifies the recipient. Under a **multi-level** program the request is
        instead advanced to the next approver up the reporting chain, and
        `approval_complete` comes back `false` with `message` naming where it
        routed.

        **Reward points vs stored dollars:** `approved_value` is supplied in
        reward POINTS (what the reviewer sees), exactly like the web approve
        modal. The award stores dollars (1 pt = 1¢ at the default rate), and the
        server converts for you — do NOT pre-divide. Omit `approved_value` to
        approve at the originally requested value.

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or the nominee's manager (anyone with
        direct reports whose report is the nominee), AND only for a nomination the
        caller may actually review. A caller who clears the coarse gate but may
        not review this specific request gets 403.

        Delete-level (owner decision 2026-10-04): requires the `destructive:recognition`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The nomination (recognition request) id, scoped to the caller's
          business.
        schema:
          type: integer
        example: 8123
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                approved_value:
                  type: integer
                  nullable: true
                  description: The award value in reward POINTS. Overrides the requested
                    value; converted server-side to stored dollars. Omit or leave
                    blank to approve at the requested value. Must be zero or greater.
                  example: 500
                approval_notes:
                  type: string
                  nullable: true
                  description: Optional reviewer notes recorded on the nomination
                    (surfaced as `reviewer_notes`).
                  example: Clear, measurable impact on the renewal — well earned.
      responses:
        '200':
          description: The request was approved (award minted) or advanced to the
            next approval level. Read `nomination.approval_complete` to tell which.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                properties:
                  message:
                    type: string
                    description: A human-readable outcome, mirroring the web flash.
                    example: Recognition request for Ada Lovelace has been approved.
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not accessible to the caller
            (error code `access_denied`), the caller is not a reviewer at all, or
            the caller may not review THIS nomination (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: No such nomination in the caller's business (error code `not_found`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: The nomination is already processed (error code `already_processed`),
            the points value is negative (error code `invalid_points`), or the approval
            could not be completed (error code `approval_failed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/{id}/reject":
    post:
      tags:
      - Recognitions
      summary: Reject a recognition request (nomination)
      description: |
        Reject an award-request nomination — the native-client mirror of the web
        reject action (RecognitionController#reject_nomination). Runs the SAME
        collaborators the web page runs, so the two surfaces cannot drift: the
        per-record ReviewableNominations authorization rule and Nomination#reject!,
        which stamps the status, records the reviewer, stores the reason, and
        notifies the nominator.

        A **reason is required** — exactly as the web modal requires it. Send it as
        `rejection_reason` (or its alias `reason`); a blank/whitespace value is
        refused with 422 `rejection_reason_required` and the nomination is left
        untouched.

        Reject is terminal: the returned `nomination.status` is `rejected` and
        `approval_complete` is `true`. The reason is echoed back in
        `reviewer_notes`.

        **Authorization** — reviewer-only, exactly like the web page: a business
        admin, a Recognitions app admin, or the nominee's manager (anyone with
        direct reports whose report is the nominee), AND only for a nomination the
        caller may actually review. A caller who clears the coarse gate but may not
        review this specific request gets 403.

        **Idempotent** — send an `Idempotency-Key` header and a retry after a
        timeout replays the stored response instead of re-running the reject (which
        would otherwise return 422 `already_processed` for a reject that had in
        fact succeeded).

        Delete-level (owner decision 2026-10-04): requires the `destructive:recognition`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The nomination (recognition request) id, scoped to the caller's
          business.
        schema:
          type: integer
        example: 8123
      - name: Idempotency-Key
        in: header
        required: false
        description: An opaque key that makes a retried reject replay the stored response
          instead of re-running.
        schema:
          type: string
        example: reject-8123-6f1c2a
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - rejection_reason
              properties:
                rejection_reason:
                  type: string
                  description: The reason for rejecting, recorded on the nomination
                    (`reviewer_notes`) and shown to the nominator. Required and non-blank.
                  example: Duplicate of last month's award for the same work.
                reason:
                  type: string
                  description: Alias for `rejection_reason` (for native clients).
                    Whichever is present and non-blank wins.
                  example: Duplicate of last month's award for the same work.
      responses:
        '200':
          description: The request was rejected. `nomination.status` is `rejected`
            and `reviewer_notes` carries the reason.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                properties:
                  message:
                    type: string
                    description: A human-readable outcome, mirroring the web flash.
                    example: Recognition request for Ada Lovelace has been rejected.
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Either the Recognitions app is not accessible to the caller
            (error code `access_denied`), the caller is not a reviewer at all, or
            the caller may not review THIS nomination (error code `forbidden`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: No such nomination in the caller's business (error code `not_found`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '422':
          description: The rejection reason is missing/blank (error code `rejection_reason_required`),
            the nomination is already processed (error code `already_processed`),
            or the reject could not be completed (error code `rejection_failed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/recognitions/nominations/{id}/withdraw":
    post:
      tags:
      - Recognitions
      summary: Withdraw your own recognition request (nomination)
      description: |
        The nominator takes back their own award request while it still waits on a
        decision — the native-client mirror of the web My Recognition row's
        Withdraw (Nomination#withdraw!). The reviewers' open asks are retired, the
        nomination's `status` becomes `cancelled` (its `display_status` reads
        "Withdrawn"), and nobody else is told. Not a reviewer action: only
        the caller's own nomination is found, anything else is a 404.

        A nomination that is no longer awaiting a decision, or that belongs to an
        award cycle, is refused with 422 `not_withdrawable`.

        **Idempotent** — send an `Idempotency-Key` header and a retry after a
        timeout replays the stored response instead of answering 422 for a
        withdrawal that had in fact succeeded.

        Delete-level: requires the `destructive:recognition` scope, like approve and
        reject (a final act, classified by the act).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The nomination (recognition request) id, one of the caller's
          own in their business.
        schema:
          type: integer
        example: 8123
      - name: Idempotency-Key
        in: header
        required: false
        description: An opaque key that makes a retried withdraw replay the stored
          response instead of re-running.
        schema:
          type: string
        example: withdraw-8123-6f1c2a
      responses:
        '200':
          description: The request was withdrawn.
          content:
            application/json:
              schema:
                type: object
                required:
                - nomination
                - message
                properties:
                  message:
                    type: string
                    example: Withdrew your nomination of Ada Lovelace.
                  nomination:
                    "$ref": "#/components/schemas/RecognitionNominationDecision"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          description: "`error.code` = `not_found` — no such nomination of the caller's
            in this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: not_found
                      message:
                        type: string
        '422':
          description: "`error.code` = `not_withdrawable` — it is no longer awaiting
            a decision, or it is a cycle nomination."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: not_withdrawable
                      message:
                        type: string
  "/recognitions/posts":
    post:
      tags:
      - Recognitions
      summary: Give recognition
      description: |
        **Give peer recognition.** The native mirror of the web give composer
        (`POST /recognition/posts` → `RecognitionController#create_recognition_post`,
        reached from Feed ▸ **+** ▸ *Give recognition*) and of the mobile give form.

        Both surfaces run `::Recognition::PeerPostCreator`, so the entire gate chain
        is the same code — peer-giving access, the anti-gaming governance guard
        (monthly cap + duplicate-recipient cooldown), keyword and AI content
        moderation, the anonymity policy, peer-points gating, manager-approval and
        spend-threshold routing, and the atomic group fan-out.

        **Who may call it.** Two gates, in order:
          1. the Recognitions app is accessible to the caller (403 `access_denied`)
          2. this caller may give peer recognition (403 `giving_not_allowed`) — the
             same `::Recognition::GivingAccess` predicate behind the web guard and
             behind `permissions.can_give` on `GET /recognitions/config`. A tenant
             that switched peer recognition off leaves giving to managers and
             Recognition admins.

        **Render the composer from `GET /recognitions/config`.** Every option
        catalog and every limit this endpoint validates against is served there —
        `values`, `tags`, `cards`, `economy.point_tiers`, `visibility_options`,
        `limits.max_recipients`, `limits.message_min` / `message_max`. The
        recipient typeahead is `GET /recognitions/employee_suggestions`; a client
        should not build its own roster, because only people that query offers are
        acceptable recipients here (active members, service / AI-agent principals
        excluded).

        **A 201 does NOT mean the recognition is live.** Read `status`:

        | `status` | what happened |
        |---|---|
        | `active` | published — it is in the feed now |
        | `posting` | held for the automated content screen. **The normal path when AI moderation is on**; it publishes moments later |
        | `pending_review` | keyword/policy hold — a human moderator decides |
        | `pending_approval` | routed to the recipient's manager (manager approval, or a points give over the spend threshold) |

        `message` is the web flash for that status, word for word, so a client can
        show it as-is instead of composing its own (and getting `posting` wrong by
        claiming the recognition was sent).

        **Group gives.** `recipient_ids` may name up to `limits.max_recipients`
        people. One submission writes one row PER recipient, atomically — a failure
        on any row rolls the whole give back, so a group give never half-posts. The
        response collapses them into ONE card naming everybody (exactly as the feed
        collapses a group give) and lists every row's id in `recognition_ids`.

        **Values.** `company_value_ids` names up to `limits.max_company_values` (3)
        of `values`, primary first; `company_value_id` (one value) is still read
        when the array is absent. **Points require a value:** `points > 0` needs at
        least one — points are always tied to a core value. `points` is silently 0 when the tenant has
        peer points switched off, so the give lands as a plain shout-out rather
        than being refused.

        **Cards.** `award_template_id` takes the picker's selection verbatim: a
        tenant card's integer id, or `"central:<slug>"` for a gallery card (whose
        art is copied into a tenant asset on submit). Either way, the card's default
        message fills a **blank** `content` — it never overwrites what the giver
        typed. `award_art_url` on the response is the art that was attached.

        **Photos.** The desktop composer uploads a file; a native client sends
        `photo_url` instead — a **public HTTPS** URL it already hosts — and the
        server fetches the image and stores it on the recognition, so this endpoint
        stays JSON and the response card carries the same `photo_url` every read
        endpoint reports. That URL is **our** stored WebP rendition, not the one you
        sent: the bytes now live here, and the source link is never referenced
        again.

        The fetch is guarded, and every one of these refuses the give with
        `invalid_photo_url` (422) **before anything is written** — no row, no
        notification:

        | rule | why |
        |---|---|
        | public HTTPS only | loopback, private, link-local, CGNAT and cloud-metadata addresses are refused, embedded credentials are refused, and each redirect hop is revalidated |
        | JPG / PNG / WebP / HEIC | decided by **sniffing the bytes**, not by your `Content-Type` header or the URL's extension |
        | 10 MB | the same cap the web composer enforces |
        | reachable within ~10s | the fetch is inline, because a recognition that published without its photo and notified everyone cannot be un-sent |

        On a **group give** the photo is fetched and stored **once** and the blob is
        shared across every row.

        **Self-recognition** is dropped, not refused — if the caller is the only
        recipient named, the give is refused with `no_recipients`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecognitionGiveRequest"
            examples:
              shoutOut:
                summary: Plain shout-out to one person
                value:
                  recipient_id: 812
                  content: You carried the migration all weekend — thank you.
              withPoints:
                summary: Points give (a company value is required)
                value:
                  recipient_ids:
                  - 812
                  - 907
                  content: The two of you turned a nasty outage into a non-event.
                  points: 250
                  company_value_ids:
                  - 4
                  - 9
                  recognition_tags:
                  - teamwork
                  - above-and-beyond
                  visibility: department
              galleryCard:
                summary: Gallery card, message left blank so the card's copy is used
                value:
                  recipient_id: 812
                  award_template_id: central:thank-you-star
                  content: ''
              anonymous:
                summary: Anonymous give (only when the tenant allows it)
                value:
                  recipient_id: 812
                  content: Quietly fixed the thing nobody else wanted to touch.
                  is_anonymous: true
              withPhoto:
                summary: With a photo, named by URL
                value:
                  recipient_id: 812
                  content: Here's the install she finished single-handed on Saturday.
                  photo_url: https://cdn.example.com/uploads/install-day.jpg
      responses:
        '201':
          description: The recognition was created. Check `status` before telling
            the user it is live.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionGiveResult"
              examples:
                published:
                  summary: Published immediately
                  value:
                    recognition:
                      type: recognition_post
                      kind: recognition
                      id: 4471
                      message: You carried the migration all weekend — thank you.
                      points: 250
                      recipient:
                        id: 812
                        name: Priya Nair
                        title: SRE
                        image:
                      giver:
                        id: 55
                        name: Sofia Ahmed
                        title: Engineering Manager
                        image:
                      anonymous: false
                      company_value: Customer First
                      occurred_at: '2026-08-17T10:24:01Z'
                      visibility: public
                      status: active
                      outcome_status: active
                      tags:
                      - teamwork
                      photo_url:
                      award_art_url:
                      boost:
                        total_points: 0
                        boosted_by_me: false
                        can_boost: false
                        amounts: []
                    recognition_ids:
                    - 4471
                    status: active
                    message: Recognition for Priya Nair has been shared!
                    giving_remaining: 750
                    unread_notification_count: 3
                posting:
                  summary: Held for the automated content screen (the normal AI-moderation
                    path)
                  value:
                    recognition:
                      type: recognition_post
                      kind: recognition
                      id: 4472
                      status: pending_review
                      outcome_status: posting
                    recognition_ids:
                    - 4472
                    status: posting
                    message: Recognition for Priya Nair is posting — it'll appear
                      in the feed in a moment.
                    giving_remaining: 750
                groupGive:
                  summary: Group give — one card, one id per recipient
                  value:
                    recognition:
                      id: 4473
                      kind: recognition
                      recipient:
                        id: 812
                        name: Priya Nair
                        title: SRE
                        image:
                      group_recipients:
                      - id: 812
                        name: Priya Nair
                        title: SRE
                        image:
                      - id: 907
                        name: Marco Diaz
                        title: Platform Engineer
                        image:
                      status: active
                      outcome_status: active
                    recognition_ids:
                    - 4473
                    - 4474
                    status: active
                    message: Recognition for Priya Nair and Marco Diaz has been shared!
                    giving_remaining: 500
                withPhoto:
                  summary: With a photo — `photo_url` is the stored rendition, not
                    the URL that was sent
                  value:
                    recognition:
                      id: 4475
                      kind: recognition
                      message: Here's the install she finished single-handed on Saturday.
                      status: active
                      outcome_status: active
                      photo_url: https://officechat.workforce.mangoapps.com/rails/active_storage/representations/redirect/eyJf.../install-day.webp
                    recognition_ids:
                    - 4475
                    status: active
                    message: Recognition for Priya Nair has been shared!
                    giving_remaining: 750
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or may not give peer recognition at all (`giving_not_allowed`) — the tenant
            limits giving to managers and Recognition admins.
        '422':
          description: |
            The give was refused. `error.code` says which rule:

            | code | meaning |
            |---|---|
            | `no_recipients` | nobody was named, or the caller named only themselves |
            | `invalid_recipients` | none of the ids are people in this business (`details.recipient_ids` echoes them) |
            | `too_many_recipients` | more than `limits.max_recipients` (`details.max_recipients`) |
            | `content_missing` | no message, and no card default message to fall back on |
            | `invalid_photo_url` | `photo_url` could not be turned into a photo — not a public HTTPS URL, not reachable, not a JPG/PNG/WebP/HEIC image, or over 10 MB. `error.message` says which. **Nothing was written**, so the identical give can be retried with a different URL |
            | `governance_blocked` | the anti-gaming guard — the monthly give cap or the duplicate-recipient cooldown. `error.message` names the limit and when it lifts |
            | `content_rejected` | content moderation blocked the message outright |
            | `invalid` | a model validation — message length, a points give with no company value, an exhausted giving allowance |
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionGiveError"
              examples:
                pointsNeedValue:
                  summary: Points with no company value
                  value:
                    error:
                      code: invalid
                      message: 'Could not send recognition: Points require a company
                        value'
                tooMany:
                  summary: Over the group cap
                  value:
                    error:
                      code: too_many_recipients
                      message: You can recognize up to 50 people at once.
                      details:
                        max_recipients: 50
                governance:
                  summary: Anti-gaming guard
                  value:
                    error:
                      code: governance_blocked
                      message: You've reached your monthly recognition limit of 20.
                photoNotAnImage:
                  summary: photo_url pointed at something that isn't an image
                  value:
                    error:
                      code: invalid_photo_url
                      message: The photo URL must point to a JPG, PNG, WebP or HEIC
                        image.
                photoNotPublic:
                  summary: photo_url is not a public HTTPS URL
                  value:
                    error:
                      code: invalid_photo_url
                      message: The photo URL must be a public HTTPS image URL.
        '429':
          description: The `give_photo` rate limit (error code `rate_limited`) — 20
            per minute per person per tenant. It bounds how often this endpoint will
            issue an OUTBOUND fetch on the caller's behalf, so it is spent ONLY when
            the request carries `photo_url`; a photo-less give is never rate limited.
            Nothing is written when it trips. `Retry-After` and `error.details.retry_after_seconds`
            carry the window.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: The database was briefly unavailable — a deadlock, a lock timeout
            or a dropped connection (error code `unavailable`), which a retry can
            get past. The give almost always rolled back, but a connection lost during
            its final commit can leave it saved, and a failed request is not stored
            against its `Idempotency-Key`, so resending repeats it. Check the caller's
            given recognitions before resending.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/recognitions/posts/{id}":
    get:
      tags:
      - Recognitions
      summary: Recognition (peer post) detail
      description: |
        Full detail of ONE peer recognition — the screen the feed card opens.

        The feed merges two persisted tables (`RecognitionPost` and `Award`)
        whose ids collide, so the TYPE lives in the path: this route resolves a
        `RecognitionPost` ("Recognition"); awards are fetched via
        `/recognitions/awards/{id}`. Both render through the SAME serializer, so
        the card body cannot drift from the feed.

        **Engagement** carries the comment and reaction COUNTS plus the FULL list
        of every reaction (emoji + who left it) and a grouped `reaction_summary`.

        **Permissions** are per-viewer, so the client renders only affordances the
        server would honour. `can_delete` is true for the author, a business admin,
        or a Recognitions app admin; `can_edit` is the same set of people but only
        while the recognition is still ACTIVE (a held or pending post is deletable
        and not editable — see `PATCH`). `can_boost` follows the peer
        pile-on rules: the giving allowance is enabled, the viewer is neither the
        giver nor the recipient, hasn't already boosted, and has points remaining;
        the `boost` block reports the running total and the affordable amounts.

        **Enumeration-safe:** a post the caller may not see under the feed's
        visibility rules (`Recognition::PostVisibility`) 404s, indistinct from a
        missing one.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      responses:
        '200':
          description: The recognition, with engagement, permissions and boost.
          content:
            application/json:
              schema:
                type: object
                required:
                - recognition
                properties:
                  recognition:
                    "$ref": "#/components/schemas/RecognitionDetail"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (error code
            `access_denied`).
        '404':
          description: No such recognition, or one the caller may not see (error code
            `not_found`). A hidden post is deliberately indistinct from a missing
            one so ids can't be enumerated.
    patch:
      tags:
      - Recognitions
      summary: Edit a recognition (peer post)
      description: |
        The ⋯ ▸ **Edit** action on the recognition detail screen, for a peer
        recognition. Native mirror of the web edit drawer
        (`RecognitionController#edit_recognition_post` / `#update_recognition_post`)
        and of the mobile edit form — all three run the same service, so the three
        surfaces cannot drift.

        **A typo fix, not a re-give.** Only two fields move:

        | field | notes |
        |---|---|
        | `message` | the recognition text. 10–1000 characters. `content` is accepted as an alias. |
        | `company_value_ids` | the whole list of values, primary first — up to `limits.max_company_values` (3), from `GET /recognitions/config`'s `values`. `company_value_id` is the one-value form. |

        Everything else is **immutable** and silently ignored if sent —
        recipient, points, visibility, anonymity, tags, status and the award card.
        That is the point: an edit must not re-settle points, re-notify the
        recipient, re-broadcast to Slack/Teams or change who can see the post.

        **Partial update.** Only the keys present in the body are written, so a
        body carrying just `message` leaves the values alone. To CLEAR them, send
        `company_value_ids: []` (or `company_value_id` as an empty string) —
        allowed only on a recognition carrying no points (a points-bearing give
        requires a value, the same rule the give form enforces, so clearing it
        answers 422).

        **Who:** the post's **author** (their own give) OR a **Recognitions
        moderator** (a business admin or the Recognitions app admin) — the same
        set as delete — and only while the recognition is **active**. A post held
        for review, pending manager approval, rejected or already removed answers
        **422 `not_editable`** with the reason, deliberately NOT a 403.
        `permissions.can_edit` on the detail GET and on every feed card is this
        exact predicate, so render the Edit entry only when that flag is true.

        **Moderation:** edited text goes back through the same AI content screen a
        new give hits, so an edit can't smuggle in content the create-time check
        would have caught — a rejection answers 422 `content_rejected` with the
        reason. The screen runs only when the text actually changed (a value-only
        edit makes no LLM call) and fails OPEN, so a provider outage never makes
        recognitions uneditable.

        **Response:** the SAME card `GET /recognitions/posts/{id}` returns, so a
        client replaces its row from the response instead of re-fetching.
        `unchanged: true` means the body matched what was already stored and
        nothing was written.

        `PUT` is accepted on this path with identical (partial-update) semantics.

        **Enumeration-safe:** a recognition the caller may neither edit nor even
        see 404s, indistinct from a missing one. One they can SEE but may not edit
        answers 403.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                  description: The recognition text. Surrounding whitespace is trimmed.
                    `content` is accepted as an alias for clients echoing the column
                    name.
                  example: You carried the whole migration all weekend — thank you
                    again
                company_value_ids:
                  type: array
                  maxItems: 3
                  items:
                    type: integer
                  description: Every company value it celebrates, primary first, from
                    `GET /recognitions/config` — up to three. `[]` clears them (pointless
                    gives only).
                  example:
                  - 12
                  - 4
                company_value_id:
                  type: integer
                  nullable: true
                  description: One company value — the list's one-value form, read
                    when `company_value_ids` is absent. Empty string clears the values
                    (pointless gives only).
                  example: 12
              example:
                message: You carried the whole migration all weekend — thank you again
                company_value_ids:
                - 12
                - 4
      responses:
        '200':
          description: The recognition was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
              examples:
                edited:
                  summary: Message and value updated
                  value:
                    recognition:
                      type: recognition_post
                      id: 4471
                      kind: recognition
                      message: You carried the whole migration all weekend — thank
                        you again
                      company_value: Ownership
                      points: 25
                      permissions:
                        can_edit: true
                        can_delete: true
                        can_boost: false
                    unchanged: false
                    message: Recognition updated.
                noop:
                  summary: Nothing changed (nothing was written)
                  value:
                    unchanged: true
                    message: Recognition updated.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or can see this recognition but is neither its author nor a moderator
            (`forbidden`).
        '404':
          description: No such recognition in the caller's business, or one they may
            neither edit nor see (`not_found`).
        '422':
          description: The recognition isn't editable right now — held, pending, rejected
            or removed (`not_editable`); the edited text was rejected by content moderation
            (`content_rejected`); the fields are invalid, e.g. a message under 10
            characters or clearing the value on a points-bearing give (a field-level
            `errors` array); or the save itself failed (`edit_failed`).
    put:
      tags:
      - Recognitions
      summary: Edit a recognition post — alias of PATCH
      description: Alias of `PATCH /recognitions/posts/{id}`, with identical partial-update
        semantics — fields the body omits are left alone, never cleared. Declared
        as its own operation so a generated client that only speaks `PUT` gets the
        method, matching `PUT /recognitions/comments/{id}`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                company_value_ids:
                  type: array
                  maxItems: 3
                  items:
                    type: integer
                company_value_id:
                  type: integer
                  nullable: true
      responses:
        '200':
          description: The recognition was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No app access, or neither the post's author nor a moderator.
        '404':
          description: No such recognition, or one they may neither edit nor see.
        '422':
          description: Not editable (`not_editable`), rejected by content moderation
            (`content_rejected`), invalid fields, or the save failed (`edit_failed`).
    delete:
      tags:
      - Recognitions
      summary: Delete a recognition (peer post)
      description: |
        The ⋯ ▸ **Delete** action on the recognition detail screen, for a peer
        recognition. Native mirror of the web
        `RecognitionController#destroy_recognition_post` — both run the same
        service, so the two surfaces cannot drift.

        **Who:** the post's **author** (their own give) OR a **Recognitions
        moderator** (a business admin or the Recognitions app admin). The
        `permissions.can_delete` flag on `GET /recognitions/posts/{id}` and on
        every feed card is this exact predicate, so the client should render the
        Delete entry only when that flag is true.

        **What it does:** a SOFT delete (`status` → `deleted`). The recognition
        leaves the feed and the recipient's profile and the row is retained for
        the audit trail; the model reverses the points itself — a settled give
        hands the recipient's credited points back and refunds the giver's spent
        allowance, an unsettled one releases the giver's reservation. The author
        is notified, with the optional `reason` included.

        **Group gives:** one submission to N recipients is N rows shown as ONE
        card. This removes only the addressed row — that recipient is
        un-recognized, the rest of the group is untouched — matching the web.

        **Idempotent:** deleting an already-deleted recognition succeeds with
        `already_deleted: true` and touches nothing (no second points reversal).

        **Enumeration-safe:** a recognition the caller may neither delete nor even
        see 404s, indistinct from a missing one. A recognition they can SEE but
        may not delete answers 403, so a client that raced a permission change
        gets a real reason instead of "gone".

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recognition` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      - name: reason
        in: query
        required: false
        description: Optional note carried into the author's deletion notice — typically
          supplied when a moderator removes someone else's recognition. May also be
          sent in a JSON body.
        schema:
          type: string
          maxLength: 500
        example: Posted to the wrong person
      responses:
        '200':
          description: The recognition was removed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionDeletion"
              examples:
                deleted:
                  summary: Removed
                  value:
                    id: 4471
                    type: recognition_post
                    deleted: true
                    status: deleted
                    already_deleted: false
                    message: Recognition removed.
                    unread_notification_count: 3
                alreadyDeleted:
                  summary: Already removed (idempotent re-issue)
                  value:
                    id: 4471
                    type: recognition_post
                    deleted: true
                    status: deleted
                    already_deleted: true
                    message: Recognition was already removed.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or can see this recognition but is neither its author nor a moderator
            (`forbidden`).
        '404':
          description: No such recognition in the caller's business, or one they may
            neither delete nor see (`not_found`).
        '422':
          description: The removal itself failed — points reversal or notification
            errored (`deletion_failed`).
  "/recognitions/awards/{id}":
    get:
      tags:
      - Recognitions
      summary: Award / Certificate detail
      description: |
        Full detail of ONE award — the native mirror of the web award permalink
        (RecognitionController#award_show) and its printable certificate.

        This route resolves an `Award`. The user perceives TWO kinds here, both
        served by this one endpoint and told apart by the `kind` field:
          * `award` — a human-given program/cycle/nomination award.
          * `certificate` — an AUTOMATED award (service anniversary, birthday,
            milestone; `award_metadata.triggered_by` present). Its giver presents
            as "System (Automated)" and it can never be boosted — there is nothing
            to pile onto on a system-generated certificate. It IS still an Award,
            so a moderator may revoke or edit one exactly as they may any award;
            clients typically hide the ⋯ menu on a certificate anyway, and
            `can_delete` / `can_edit` report the honest server capability rather
            than that UI choice.

        Awards support reactions and comments identically to posts (same
        `engagement` block, same FULL reactions list). Awards are NOT boostable,
        so `boost` is null. `award_art_url` is the gold-framed certificate page.

        **Permissions:** awards have no boost (`can_boost` false). Both write
        capabilities are moderation acts held by the same actor — a Recognitions
        moderator (a business admin or the Recognitions app admin) while the award
        is still active: `can_delete` maps to the admin REVOKE
        (`DELETE /recognitions/awards/{id}`) and `can_edit` to the citation /
        value fix (`PATCH /recognitions/awards/{id}`). Never the giver's own and
        never the recipient's. There is no age limit on either: the web has never
        applied `Award#can_be_revoked?`'s 30-day window, so neither does this.

        **Enumeration-safe:** a non-public award the caller neither gave nor
        received (and isn't an admin for) 404s, mirroring
        RecognitionController#load_visible_award.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
        example: 991
      responses:
        '200':
          description: The award (or automated certificate), with engagement and permissions.
          content:
            application/json:
              schema:
                type: object
                required:
                - recognition
                properties:
                  recognition:
                    "$ref": "#/components/schemas/RecognitionDetail"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (error code
            `access_denied`).
        '404':
          description: No such award, or one the caller may not see (error code `not_found`).
    patch:
      tags:
      - Recognitions
      summary: Edit a recognition (award / certificate)
      description: |
        The ⋯ ▸ **Edit** action on the recognition detail screen when the card is an
        Award (or an automated Certificate, which is an Award). Runs the same
        service as the peer-post PATCH above.

        Editing an award is a **moderation** act, so — unlike a peer post, which
        its author may fix — this is **moderator only**: a business admin or the
        Recognitions app admin, never the giver's own touch-up and never the
        recipient's. That is the same actor rule as the revoke on this path,
        because an award's citation is program-level copy rather than one person's
        words. Only an **active** award is editable; a revoked or expired one
        answers **422 `not_editable`**.

        **Two fields move:**

        | field | notes |
        |---|---|
        | `message` | the award citation — the `description` column, which is what the card serializes as `message`. 10–1000 characters. `description` is accepted as an alias. |
        | `company_value_ids` | the whole list of values, primary first — up to `limits.max_company_values` (3), from `GET /recognitions/config`'s `values`; `[]` clears them. `company_value_id` is the one-value form. |

        Everything else is **immutable** and silently ignored if sent: `title`,
        `value` (the points/amount), recipient, giver, program, category,
        `is_public` and `status`. An edit therefore never re-credits store points,
        never redraws a manager's group budget and never changes who can see the
        award.

        **Partial update, response and enumeration safety** are exactly as
        documented for `PATCH /recognitions/posts/{id}` — only the keys present are
        written, the response is the same card the detail GET returns plus
        `unchanged` / `message`, and an award the caller may neither edit nor see
        404s. `PUT` is accepted with identical semantics.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
        example: 991
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                  description: The award citation (the `description` column). Surrounding
                    whitespace is trimmed. `description` is accepted as an alias.
                  example: Delivered the Q3 platform migration two weeks ahead of
                    plan
                company_value_ids:
                  type: array
                  maxItems: 3
                  items:
                    type: integer
                  description: Every company value the award celebrates, primary first,
                    from `GET /recognitions/config` — up to three. `[]` clears them.
                  example:
                  - 12
                  - 4
                company_value_id:
                  type: integer
                  nullable: true
                  description: One company value — the list's one-value form, read
                    when `company_value_ids` is absent. Empty string clears the values.
                  example: 12
              example:
                message: Delivered the Q3 platform migration two weeks ahead of plan
      responses:
        '200':
          description: The award was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
              examples:
                edited:
                  summary: Citation updated
                  value:
                    recognition:
                      type: award
                      id: 991
                      kind: award
                      title: Excellence in Delivery
                      message: Delivered the Q3 platform migration two weeks ahead
                        of plan
                      permissions:
                        can_edit: true
                        can_delete: true
                        can_boost: false
                    unchanged: false
                    message: Recognition updated.
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or can see this award but is not a Recognitions moderator (`forbidden`)
            — including its own giver and its recipient.
        '404':
          description: No such award in the caller's business, or one they may neither
            edit nor see (`not_found`).
        '422':
          description: The award is no longer editable — revoked or expired (`not_editable`);
            the edited citation was rejected by content moderation (`content_rejected`);
            the fields are invalid, e.g. a citation under 10 characters (a field-level
            `errors` array); or the save itself failed (`edit_failed`).
    put:
      tags:
      - Recognitions
      summary: Edit a recognition (award / certificate) — alias of PATCH
      description: Alias of `PATCH /recognitions/awards/{id}`, with identical partial-update
        semantics — fields the body omits are left alone, never cleared. Declared
        as its own operation so a generated client that only speaks `PUT` gets the
        method, matching `PUT /recognitions/comments/{id}`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                message:
                  type: string
                  minLength: 10
                  maxLength: 1000
                company_value_ids:
                  type: array
                  maxItems: 3
                  items:
                    type: integer
                company_value_id:
                  type: integer
                  nullable: true
      responses:
        '200':
          description: The recognition was updated (or already matched the body).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionEdit"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No app access, or the caller is not a Recognitions moderator.
        '404':
          description: No such award, or one they may neither edit nor see.
        '422':
          description: Not editable (`not_editable`), rejected by content moderation
            (`content_rejected`), invalid fields, or the save failed (`edit_failed`).
    delete:
      tags:
      - Recognitions
      summary: Delete a recognition (award / certificate) — admin revoke
      description: |
        The ⋯ ▸ **Delete** action on the recognition detail screen when the card is
        an Award (or an automated Certificate, which is an Award). Native mirror of
        the web `RecognitionController#revoke_award`.

        For an award, "delete" is the admin **REVOKE**: `status` → `revoked`, the
        giver's group budget is refunded and the recipient's store points are
        reversed. The recipient is notified. `reason` is recorded on the award
        (`award_metadata.revoked_reason`); when omitted it defaults to
        "Revoked by {caller name}", so the notice always names a person.

        **Who:** a **Recognitions moderator** only — a business admin or the
        Recognitions app admin. Never the giver's own undo and never the
        recipient's. Only an **active** award can be revoked; there is no age
        limit (`Award#can_be_revoked?`'s 30-day window has never been enforced on
        the web, so it isn't enforced here either).

        This is the same predicate `permissions.can_delete` reports on
        `GET /recognitions/awards/{id}`.

        **Enumeration-safe:** an award the caller may neither revoke nor see 404s.
        Re-issuing the request on an already-revoked award answers **422
        `not_revocable`** — deliberately NOT a 403, which would be both wrong and
        unactionable.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recognition` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award id, scoped to the caller's business.
        schema:
          type: integer
        example: 991
      - name: reason
        in: query
        required: false
        description: Why the award is being revoked. Stored on the award and included
          in the recipient's notice. Defaults to "Revoked by {caller name}". May also
          be sent in a JSON body.
        schema:
          type: string
          maxLength: 500
        example: Awarded to the wrong person
      responses:
        '200':
          description: The award was revoked.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionDeletion"
              examples:
                revoked:
                  summary: Revoked
                  value:
                    id: 991
                    type: award
                    deleted: true
                    status: revoked
                    already_deleted: false
                    message: Award revoked. Dana Marsh's points were reversed.
                    unread_notification_count: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (`access_denied`),
            or is not a Recognitions moderator (`forbidden`).
        '404':
          description: No such award in the caller's business, or one they may neither
            revoke nor see (`not_found`).
        '422':
          description: The award is not active, so there is nothing to revoke — `not_revocable`
            (e.g. it was already revoked or has expired). A revoke that itself failed
            answers `deletion_failed`.
  "/recognitions/awards/{id}/certificate":
    get:
      tags:
      - Recognitions
      summary: Award certificate — the "View Certificate" overlay for one award
      description: |
        One award's gold-framed **Certificate of Recognition** — the native mirror
        of the printable web page (`/recognition/awards/{id}/card`) and the overlay
        behind the **View Certificate** button on the Award Results screen.

        Returns the certificate's fields in the order the printed page renders
        them: the award title, the recipient, who awarded it, the italic citation
        quote, the program / points / company-value chips, then the footer's
        issuing organization and date. The fixed copy the page prints around those
        fields — the gold eyebrow, "is proudly presented to", the "awarded by"
        prefix, the QR caption — is in `meta`, so the client hardcodes no strings.

        **The same block the results screen embeds.** `certificate` is byte-identical
        to `winners[].certificate` in
        `GET /recognitions/award_cycles/{id}/results`; both come from one
        serializer. A client that already holds the results payload can render the
        overlay from it and refresh from here, and the two can never disagree.

        **Why the endpoint exists at all**, given the results payload carries it:
        a certificate is reachable from places that hold an **award id and no
        cycle** — its own scan-to-view QR and share link, a push deep link, a My
        Recognition or feed row — and from awards that never came from a cycle at
        all: a manager Quick Award, a nomination award, or an automated lifecycle
        certificate (an anniversary or milestone, which the feed labels
        "Certificate"). This is the award-keyed lookup for all of them.

        **Not gated on award cycles.** Certificates are not a Model B feature, and
        the awards above exist in tenants that never enabled cycles — gating here
        would 403 the majority of certificates. Access is gated only the way every
        endpoint in this namespace is: the Recognitions app must be enabled for the
        tenant and the caller must be inside its audience.

        **Two different URLs, deliberately.** `certificate_url` is the printable
        page for Print / Save; `share_url` is the award permalink — what a Share
        action sends and what the QR should encode. Encoding the print page would
        dead-end a phone that scanned it in a print dialog.

        **Opening your own certificate acknowledges it**, silently and with no
        button, exactly as both web award surfaces do — the mobile client's
        confetti stops on the strength of this read. Only the recipient's own read
        writes anything; a giver, an admin or a colleague opening the same
        certificate writes nothing.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The **award's** id — `certificate.award_id` on any winner card,
          or the `id` of any `award` / `certificate` row in the feed. Scoped to the
          caller's tenant.
        example: 23
      responses:
        '200':
          description: The certificate for one award
          content:
            application/json:
              schema:
                type: object
                required:
                - certificate
                - meta
                properties:
                  certificate:
                    "$ref": "#/components/schemas/RecognitionAwardCycleCertificate"
                  meta:
                    "$ref": "#/components/schemas/RecognitionCertificateMeta"
                  unread_notification_count:
                    type: integer
                    description: The caller's unread notification count, for the app
                      badge.
                    example: 3
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Recognitions app is not enabled for the tenant, or this
            user is outside the app's audience (error code `access_denied`).
        '404':
          description: |
            No certificate to serve, as one indistinguishable answer (error code
            `not_found`) for all of:

            * no award with that id in the caller's tenant
            * an award the caller may not see — the canonical rule is public, or
              the caller is its recipient or giver, or the caller is an admin
            * an award that is no longer **active** (revoked, expired or deleted),
              which stops being printable on the web page too — including for its
              own recipient

            The collapse is deliberate. A distinct 403 would confirm that a
            private award with that id exists, which is exactly what a caller
            walking the id space is trying to learn.
  "/recognitions/comments":
    get:
      tags:
      - Recognitions
      summary: List the comments on a recognition / award / certificate
      description: |
        The comment thread of ONE recognition — the native mirror of the web
        thread (RecognitionController#fetch_comments).

        Recognition comments are polymorphic (`RecognitionComment#commentable`),
        so ONE endpoint serves BOTH commentable kinds, selected by `item_type`:
          * `recognition_post` — a peer recognition post.
          * `award` — a program/cycle/nomination award.
          * `certificate` — accepted as an ALIAS of `award` (a certificate is a
            display variant of an award, not a separate commentable). The response
            normalizes `item_type` back to `award`.

        Returns only ACTIVE comments, oldest-first, paginated. Each comment carries
        its author, content, reaction summary, and per-viewer `can_edit`/`can_delete`
        flags — the SAME predicates the write endpoints enforce, so a client never
        renders a control the API would refuse.

        Threads are ONE level deep. `comments` holds only TOP-LEVEL rows; a reply
        is inlined under the comment it answers, in that comment's `replies` array
        (and carries `parent_id`). A reply is never also a top-level row, so the
        client draws the thread exactly as returned. `meta.total_count` therefore
        counts TOP-LEVEL comments — it is the page count for this list, and is
        smaller than the recognition detail's `comments_count`, which counts every
        visible row (top-level plus their active replies).

        **Enumeration-safe:** a recognition the caller may not see under the feed's
        visibility rules 404s, indistinct from a missing one — so the thread of a
        private/team/department recognition can't be read by guessing ids.
      security:
      - BearerAuth: []
      parameters:
      - name: item_type
        in: query
        required: true
        description: The commentable kind — `recognition_post`, `award`, or `certificate`
          (alias of `award`).
        schema:
          type: string
          enum:
          - recognition_post
          - award
          - certificate
      - name: item_id
        in: query
        required: true
        description: The recognition post / award id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Page size (default 20, clamped to 50).
        schema:
          type: integer
          default: 20
          maximum: 50
      responses:
        '200':
          description: The active comment thread, oldest-first, paginated.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_type
                - item_id
                - comments
                - meta
                properties:
                  item_type:
                    type: string
                    enum:
                    - recognition_post
                    - award
                    description: The canonical commentable type (the `certificate`
                      alias is normalized to `award`).
                  item_id:
                    type: integer
                    example: 4471
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionComment"
                  meta:
                    "$ref": "#/components/schemas/RecognitionCommentPageMeta"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller has no access to the Recognitions app (error code
            `access_denied`).
        '404':
          description: No such recognition, or one the caller may not see (error code
            `not_found`).
        '422':
          description: "`item_type` was not one of the accepted values (error code
            `invalid_item_type`)."
    post:
      tags:
      - Recognitions
      summary: Add a comment to a recognition / award / certificate
      description: |
        Post a comment on a recognition post or award — the native mirror of
        RecognitionController#comment.

        `item_type` selects the commentable (`recognition_post` / `award` /
        `certificate`-as-alias). Content is validated (1–500 characters) and run
        through the tenant's content-moderation policy: when the policy HOLDS the
        comment, it is created hidden pending review and the response returns
        `held: true` so the client can show the same "submitted for review" state
        the web does.

        `parent_id` (optional) posts a threaded REPLY to an existing comment on
        the SAME recognition — the one-level threading the web feed offers.
        A `parent_id` naming a reply is re-pointed at that reply's top-level
        parent (threads never nest deeper); a `parent_id` that names no ACTIVE
        comment on this recognition is ignored and the text posts as a top-level
        comment rather than being rejected.

        Requires the tenant's comments toggle to be on; otherwise `403`
        (`comments_disabled`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_type
              - item_id
              - content
              properties:
                item_type:
                  type: string
                  enum:
                  - recognition_post
                  - award
                  - certificate
                item_id:
                  type: integer
                  example: 4471
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
                  example: Fantastic work — well deserved!
                parent_id:
                  type: integer
                  nullable: true
                  example: 991
                  description: Optional. The TOP-LEVEL comment on this same recognition
                    that this one replies to. Omit (or send null) for a top-level
                    comment. A reply's own id is re-pointed at its parent — threads
                    are one level deep — and an id that matches no active comment
                    on this recognition is ignored rather than rejected.
      responses:
        '201':
          description: The created comment (hidden if moderation held it).
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                - held
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecognitionComment"
                  held:
                    type: boolean
                    description: True when the tenant's moderation policy held the
                      comment for review (created hidden).
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or comments
            are disabled for the tenant (`comments_disabled`).
        '404':
          description: No such recognition, or one the caller may not see (`not_found`).
        '422':
          description: Blank/too-long content, or an invalid `item_type`.
  "/recognitions/comments/{id}":
    patch:
      tags:
      - Recognitions
      summary: Edit a comment or reply
      description: |
        Edit the text of a recognition comment. **Author-only** — only the person
        who wrote a comment may reword it (an admin may delete but not rewrite
        someone else's words). Editing re-runs content moderation and stamps
        `edited_at`, so the `edited` flag flips true. `PUT` is accepted as an alias
        of `PATCH`.

        **Replies are edited by this same route**, addressed by the reply's own
        `id`. Author-only applies to the reply's own author: owning the top-level
        comment a reply hangs under does NOT confer the right to reword that reply.

        Only `content` is editable. A `parent_id` sent in the body is **ignored** —
        a comment cannot be re-parented, so a reply can never be moved under a
        different thread after the fact, and the response's `parent_id` is always
        the one it was created with.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The comment id, scoped to the caller's business.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
                  example: Edited — thank you again!
      responses:
        '200':
          description: The updated comment.
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                - held
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecognitionComment"
                  held:
                    type: boolean
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller is not the comment's author (error code `forbidden`),
            or has no access to the Recognitions app, or comments are disabled.
        '404':
          description: No such comment in the caller's business, or its recognition
            is not visible (`not_found`).
        '422':
          description: Blank/too-long content.
    put:
      tags:
      - Recognitions
      summary: Edit a comment (alias of PATCH)
      description: Alias of `PATCH /recognitions/comments/{id}`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
      responses:
        '200':
          description: The updated comment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecognitionComment"
                  held:
                    type: boolean
        '403':
          description: Not the author, no app access, or comments disabled.
        '404':
          description: No such comment, or its recognition is not visible.
        '422':
          description: Blank/too-long content.
    delete:
      tags:
      - Recognitions
      summary: Delete a comment or reply
      description: |
        Remove a recognition comment. Allowed for the comment's **author** OR a
        **Recognitions moderator** (a business admin or the Recognitions app admin)
        — the same widening the feed uses for `can_delete` on a post.

        Soft-deletes (status → `deleted`): the comment leaves the active thread and
        the recognition's comment count is recomputed, while the row is retained for
        the audit trail.

        **Replies are deleted by this same route**, addressed by the reply's own
        `id`, under the same author-or-moderator rule applied to the reply's own
        author. Deleting a reply leaves its parent — and the rest of the thread —
        in place.

        **Deleting a top-level comment takes its replies out of view with it.** A
        reply renders only underneath its parent, so once the parent is gone the
        replies render nowhere: they are omitted from `GET /recognitions/comments`
        and shed from the recognition's `comments_count` along with the parent (one
        parent with one reply drops the count by 2). The reply rows themselves stay
        `active` in the audit trail and are **never promoted to top-level** — so a
        client must not expect them to reappear as standalone comments.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recognition` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The comment id, scoped to the caller's business.
        schema:
          type: integer
      responses:
        '200':
          description: The comment was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - deleted
                properties:
                  id:
                    type: integer
                    example: 991
                  deleted:
                    type: boolean
                    example: true
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: The caller is neither the author nor a moderator (error code
            `forbidden`).
        '404':
          description: No such comment in the caller's business, or its recognition
            is not visible (`not_found`).
  "/recognitions/posts/{id}/boost":
    post:
      tags:
      - Recognitions
      summary: Boost a recognition (pile-on points)
      description: |
        Add **5, 10 or 25 points from your own monthly giving allowance** to
        somebody else's recognition — the native mirror of the web feed's
        ⋯ ▸ **Boost** (`RecognitionController#boost_recognition_post`). Both
        surfaces run the same `Recognition::BoostService`, so the amounts, the
        allowance spend and the eligibility rules cannot drift.

        It **settles immediately**: the amount leaves the caller's allowance and
        lands in the recipient's store balance, and the recipient is notified.
        There is no undo — a boost is a one-tap celebration, not a draft.

        **Peer recognition posts ONLY.** Awards, certificates and milestone
        recognitions are not boostable — that is why the path is `/posts/{id}`
        and there is no `/awards/{id}/boost` twin. `GET /recognitions/awards/{id}`
        reports `permissions.can_boost: false` for the same reason.

        **Who may call it** — four gates, in the order the server applies them:

        | # | Gate | Failure |
        |---|------|---------|
        | 1 | The Recognitions app is accessible to the caller | `403 access_denied` |
        | 2 | The tenant has peer giving points switched on | `403 boosting_disabled` |
        | 3 | The caller may give peer recognition (everyone when the tenant's peer toggle is on; managers and admins always) | `403 forbidden` |
        | 4 | The post exists in the caller's business AND is visible to them | `404 not_found` |

        Past those, the service refuses with `422`: your own give, a recognition
        you received, an amount outside 5/10/25, a second boost on the same post,
        an inactive recipient, or an allowance that can't cover the amount
        (`insufficient_allowance`).

        **Don't guess whether the caller may boost** — read the `boost` block on
        `GET /recognitions/feed` or `GET /recognitions/posts/{id}`: `can_boost`
        and `amounts` are computed by the same rules this endpoint enforces, and
        `amounts` is already capped to the caller's remaining allowance.

        **Enumeration-safe:** a post in another tenant, or one this caller may not
        see, returns `404` — indistinct from a missing one, so ids can't be probed
        to discover that a private recognition exists.

        The success payload carries the post's REFRESHED `boost` block and the
        caller's new `giving_remaining`, so a client patches the card it already
        has instead of re-fetching the feed. Every `422` carries the same two
        values under `error.details`, so a client whose card was stale (someone
        else's boost landed first, the wallet ran dry in another tab) can
        reconcile straight from the refusal.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition POST id (`RecognitionPost`), scoped to the caller's
          business.
        schema:
          type: integer
        example: 4471
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - points
              properties:
                points:
                  type: integer
                  enum:
                  - 5
                  - 10
                  - 25
                  description: The boost amount, spent from the caller's monthly giving
                    allowance. Anything else is refused with `invalid_boost`.
                  example: 10
      responses:
        '201':
          description: The boost was applied and settled.
          content:
            application/json:
              schema:
                type: object
                required:
                - boost
                - recognition
                - giving_remaining
                properties:
                  boost:
                    "$ref": "#/components/schemas/RecognitionBoostReceipt"
                  recognition:
                    "$ref": "#/components/schemas/RecognitionBoostTarget"
                  giving_remaining:
                    type: integer
                    description: The caller's remaining monthly giving allowance AFTER
                      this boost.
                    example: 990
                  message:
                    type: string
                    description: The same confirmation the web flashes — safe to show
                      verbatim.
                    example: Boosted Dana Wu's recognition with 10 points. You have
                      990 left this month.
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), the tenant
            has peer giving points switched off (`boosting_disabled`), or this caller
            may not give peer recognition (`forbidden`).
        '404':
          description: No such recognition post in the caller's business, or one they
            may not see (`not_found`).
        '422':
          description: The boost was refused — `invalid_boost` (your own give, a recognition
            you received, an amount outside 5/10/25, an inactive recipient, or you
            have already boosted this post) or `insufficient_allowance` (your remaining
            allowance can't cover the amount). Nothing is written and no points move.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionBoostError"
  "/recognitions/reactions":
    get:
      tags:
      - Recognitions
      summary: Who reacted to a recognition
      description: "The people who reacted to ONE recognition, most recent first —
        the\npaginated form of the `engagement.reactions` list that\n`GET /recognitions/posts/{id}`
        ships inline. Reach for it when a\nrecognition has more reactions than a detail
        payload should carry, or to\nshow \"everyone who sent \U0001F389\" with `?emoji=`.\n\n**One
        polymorphic endpoint for every reactable kind**, keyed by\n`item_type` + `item_id`,
        exactly as the comment thread is — reactions live\nin `platform_reactions`
        and the association is polymorphic, so a path per\nkind would be pure duplication.\n\n|
        `item_type` | Resolves | Notes |\n|---|---|---|\n| `recognition_post` | A
        peer recognition | |\n| `award` | An award or certificate | |\n| `certificate`
        | *alias of* `award` | A certificate is an Award rendered with a certificate
        template |\n| `comment` | A comment on either | |\n\nThe response always echoes
        the **canonical** `item_type`, so a client that\nposted `certificate` can
        still key its cache off the response alone.\n\nA client holding a **comment
        id** can use the comment-scoped twin instead —\n`GET /recognitions/comments/{id}/reactions`
        — which answers identically\nwithout needing the `(item_type, item_id)` pair.\n\nAlongside
        the page, the response carries a **`summary`** block: per-emoji\ncounts plus
        the caller's own glyphs, computed over the **whole set** rather\nthan the
        page, so a reactor sheet's per-emoji tabs need no second\nround-trip and their
        counts don't shrink as the reader pages. `?emoji=`\nnarrows the **list** only
        — never the summary, because the tabs must keep\nshowing the glyphs the reader
        can switch *to*.\n\n**Not gated on the tenant's reaction switch.** Turning
        reactions off\nretires the affordance; it does not retract the reactions people
        already\nleft, and the detail screen keeps showing them. Read `reactions_enabled`\nfrom
        `GET /recognitions/config` (or from a write response) to decide\nwhether to
        render the bar.\n\n**Enumeration-safe:** a recognition in another tenant,
        or one this caller\nmay not see, returns `404` — indistinct from a missing
        one.\n\nCosts a **constant number of queries** however many people reacted:
        the\npage is one indexed read plus one batched avatar load.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: item_type
        in: query
        required: true
        description: Which kind of recognition the reactions belong to.
        schema:
          type: string
          enum:
          - recognition_post
          - award
          - certificate
          - comment
        example: recognition_post
      - name: item_id
        in: query
        required: true
        description: The id of that recognition, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      - name: emoji
        in: query
        required: false
        description: Narrow the list to a single glyph. Omit for every reaction.
        schema:
          type: string
        example: "\U0001F44D"
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Capped at 100.
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: The reactions on this recognition, most recent first.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_type
                - item_id
                - reactions
                - summary
                - meta
                properties:
                  item_type:
                    type: string
                    enum:
                    - recognition_post
                    - award
                    - recognition_comment
                    description: The CANONICAL type, even when the request used an
                      alias.
                  item_id:
                    type: integer
                    example: 4471
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionReactor"
                  summary:
                    "$ref": "#/components/schemas/RecognitionReactionSummary"
                  meta:
                    "$ref": "#/components/schemas/RecognitionReactionsPage"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
        '422':
          description: "`item_type` is not a recognition kind (`invalid_item_type`).
            `error.details.allowed` lists the ones that are."
    post:
      tags:
      - Recognitions
      summary: React to a recognition (toggle)
      description: "**Capture a reaction** on a recognition post, an award/certificate,
        or a\ncomment on either — the native mirror of the web reaction bar\n(`POST
        /recognition/reactions/toggle` → `RecognitionController#react`).\nBoth surfaces
        run the same `Recognition::ReactionService`, so the tenant\nswitch, the accepted
        kinds, the audience gate, the emoji allowlist and the\nrate limit cannot drift.\n\n**TOGGLE
        semantics**, matching the web bar and\n`Platform::Reactable#toggle_reaction`:
        re-sending an emoji the caller\nalready left **removes** it; otherwise it
        adds it. `reacted` says which way\nthis call went, and the status line says
        it too — **`201` when a reaction\nwas added, `200` when one was removed**.
        A viewer may hold several\n*different* emojis at once; each is its own row,
        toggled independently.\nWhen you need \"off, whatever the current state is\",
        use `DELETE` — it is\nidempotent and a retry can never flip the reaction back
        on.\n\n**Send an `Idempotency-Key` header on a retry-prone connection.** Without\none,
        a retried POST toggles a second time and silently undoes the first.\nWith
        one, the stored response is replayed (`X-Idempotency-Cached: true`)\nand nothing
        is written twice.\n\n**The emoji allowlist is per model** — `Platform::Reactable`'s\n`reactable_emoji_set`.
        Recognition posts, awards and recognition comments\nall accept **\U0001F44D
        ❤️ \U0001F389 \U0001F44F ⭐ \U0001F525**; anything else is refused with\n`invalid_emoji`,
        and `error.details.allowed` carries the set so a client\ncan correct itself
        without a second round-trip.\n\n**Who may call it** — four gates, in the order
        the server applies them:\n\n| # | Gate | Failure |\n|---|------|---------|\n|
        1 | The Recognitions app is accessible to the caller | `403 access_denied`
        |\n| 2 | The caller is inside the shared 30/minute `react` bucket — the SAME
        counter the web bar spends, so a client can't out-run the web limit by switching
        surface | `429 rate_limited` (with `Retry-After`) |\n| 3 | The tenant has
        reactions switched on | `403 reactions_disabled` |\n| 4 | The recognition
        exists in the caller's business AND is visible to them | `404 not_found` |\n\n**Enumeration-safe:**
        a recognition in another tenant, a private give the\ncaller isn't part of,
        and a missing id all return the same `404`.\n\nThe success payload carries
        the recognition's **refreshed reaction block**,\nkeyed exactly as `engagement`
        is keyed on `GET /recognitions/feed` and\n`GET /recognitions/posts/{id}` —
        so a client patches the card it already\nhas instead of re-fetching the screen.
        It is computed with two aggregate\nqueries, so a recognition with a thousand
        reactions costs the same as one\nwith three.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A client-generated key. A retry carrying the same key replays
          the stored response instead of toggling again.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - item_type
              - item_id
              - emoji
              properties:
                item_type:
                  type: string
                  enum:
                  - recognition_post
                  - award
                  - certificate
                  - comment
                  description: Which kind of recognition to react to. `certificate`
                    aliases `award`; `comment` resolves a comment on either. The web
                    bar's class-name spellings (`RecognitionPost`, `Award`, `RecognitionComment`)
                    are accepted too.
                  example: recognition_post
                item_id:
                  type: integer
                  description: The id of that recognition, scoped to the caller's
                    business.
                  example: 4471
                emoji:
                  type: string
                  enum:
                  - "\U0001F44D"
                  - "❤️"
                  - "\U0001F389"
                  - "\U0001F44F"
                  - "⭐"
                  - "\U0001F525"
                  description: The glyph to toggle. Must be in the target model's
                    `reactable_emoji_set`.
                  example: "\U0001F389"
      responses:
        '201':
          description: The reaction was ADDED.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '200':
          description: 'The caller already held that emoji, so the toggle REMOVED
            it (`reacted: false`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`). Nothing is written.
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
        '422':
          description: The reaction was refused — `invalid_item_type` (not a recognition
            kind), `emoji_required` (blank), or `invalid_emoji` (outside the model's
            allowlist). `error.details.allowed` carries the accepted values. Nothing
            is written.
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
            The `Retry-After` header and `error.details.retry_after_seconds` both
            carry the window. Nothing is written.
    delete:
      tags:
      - Recognitions
      summary: Remove your reaction (idempotent)
      description: |
        Remove the caller's own `emoji` from one recognition — the unambiguous half
        of the toggle above.

        **Idempotent.** Removing a reaction that isn't there succeeds with
        `removed: false`, so a client retrying a dropped request never flips the
        reaction back on the way a repeated `POST` would. This is the call to use
        when your UI knows the target state ("off") rather than the transition.

        **Scoped to the caller's own row** — it can never remove somebody else's
        reaction, and it leaves the caller's *other* emojis on the same recognition
        alone.

        Same four gates as `POST`, in the same order, including the shared
        30/minute `react` bucket. Answers with the same refreshed reaction block,
        plus `removed`.
      security:
      - BearerAuth: []
      parameters:
      - name: item_type
        in: query
        required: true
        schema:
          type: string
          enum:
          - recognition_post
          - award
          - certificate
          - comment
        example: recognition_post
      - name: item_id
        in: query
        required: true
        schema:
          type: integer
        example: 4471
      - name: emoji
        in: query
        required: true
        description: The glyph to remove.
        schema:
          type: string
        example: "\U0001F389"
      responses:
        '200':
          description: The caller holds no such reaction any more. `removed` says
            whether this call is what removed it.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/RecognitionReactionState"
                - type: object
                  properties:
                    removed:
                      type: boolean
                      description: True when this call deleted a row; false when there
                        was nothing to delete (still a success — the endpoint is idempotent).
                      example: true
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`).
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
        '422':
          description: "`invalid_item_type`, `emoji_required`, or `invalid_emoji`."
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
  "/recognitions/comments/{id}/reactions":
    parameters:
    - name: id
      in: path
      required: true
      description: The comment's id, scoped to the caller's business. A comment on
        a peer recognition, on an award/certificate, or a reply — a reply is just
        a comment with a parent and reacts on this same path.
      schema:
        type: integer
      example: 88213
    get:
      tags:
      - Recognitions
      summary: Who reacted to a comment
      description: |
        **Fetch the reactions on ONE comment** in a recognition thread, most
        recent first — the comment-scoped twin of
        `GET /recognitions/reactions`, in the shape the News Feed API uses for the
        same case (`GET /comments/{id}/reactions`). Reach for it when you already
        hold a comment id: a client rendering a thread has comment ids, not
        `(item_type, item_id)` pairs.

        It is a **path, not a second implementation.** This endpoint and
        `/recognitions/reactions` run the same action body over the same
        `Recognition::ReactionService` the **web comment row's reaction bar** runs
        (`shared/recognition/_feed_comment` → `shared/_platform_reaction_bar` →
        `POST /recognition/reactions/toggle`), so the three surfaces cannot
        disagree. Passing `?item_type=comment&item_id={id}` to
        `/recognitions/reactions` returns a byte-identical body.

        Alongside the page it carries a **`summary`** block — per-emoji counts plus
        the caller's own glyphs — computed over the **whole set** rather than the
        page, so a reactor sheet's per-emoji tabs need no second round-trip and
        their counts don't shrink as the reader pages. `?emoji=` narrows the
        **list** only, never the summary: the tabs must keep showing the glyphs the
        reader can switch *to*.

        **Not gated on the tenant's reaction switch.** Turning reactions off
        retires the affordance; it does not retract what people already left, and
        the web thread keeps showing them. `summary.reactions_enabled` reports the
        switch so a client reading only this endpoint still knows to hide the bar.

        **The audience gate is the comment's PARENT recognition** — the same rule
        the web thread applies. A comment in another tenant, one hanging off a
        private recognition this caller isn't part of, and a missing id all return
        the same `404`, so ids can't be enumerated to discover a private thread.

        Costs a **constant number of queries** however many people reacted: one
        indexed page read, one batched avatar load, two aggregates for the summary.
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: false
        description: Narrow the LIST to a single glyph. Omit for every reaction.
        schema:
          type: string
        example: "\U0001F44D"
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Capped at 100.
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: The reactions on this comment, most recent first.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_type
                - item_id
                - reactions
                - summary
                - meta
                properties:
                  item_type:
                    type: string
                    enum:
                    - recognition_comment
                    description: Always `recognition_comment` — the kind is fixed
                      by the path.
                  item_id:
                    type: integer
                    description: The comment's id, echoing the path.
                    example: 88213
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecognitionReactor"
                  summary:
                    "$ref": "#/components/schemas/RecognitionReactionSummary"
                  meta:
                    "$ref": "#/components/schemas/RecognitionReactionsPage"
                  unread_notification_count:
                    type: integer
                    example: 3
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such comment in the caller's business, or one whose parent
            recognition they may not see (`not_found`).
    post:
      tags:
      - Recognitions
      summary: React to a comment (toggle)
      description: "**Capture a reaction** on one comment in a recognition thread
        — the native\nmirror of the web comment row's own reaction bar\n(`shared/recognition/_feed_comment`
        renders `shared/_platform_reaction_bar`\nwith the comment as the reactable,
        posting to\n`POST /recognition/reactions/toggle`). Both surfaces run the same\n`Recognition::ReactionService`,
        so the tenant switch, the audience gate,\nthe emoji allowlist and the rate
        limit cannot drift.\n\n**TOGGLE semantics**, matching the web bar: re-sending
        an emoji the caller\nalready left **removes** it; otherwise it adds it. `reacted`
        says which way\nthis call went, and the status line says it too — **`201`
        when a reaction\nwas added, `200` when one was removed**. A viewer may hold
        several\n*different* emojis on the same comment, each toggled independently.
        When\nyou need \"off, whatever the current state is\", use `DELETE`.\n\n**Send
        an `Idempotency-Key` header on a retry-prone connection.** Without\none, a
        retried POST toggles a second time and silently undoes the first.\nWith one,
        the stored response is replayed (`X-Idempotency-Cached: true`).\n\nComments
        accept **\U0001F44D ❤️ \U0001F389 \U0001F44F ⭐ \U0001F525**. Anything else
        is refused with\n`invalid_emoji`, and `error.details.allowed` carries the
        set.\n\n**Who may call it** — four gates, in the order the server applies
        them:\n\n| # | Gate | Failure |\n|---|------|---------|\n| 1 | The Recognitions
        app is accessible to the caller | `403 access_denied` |\n| 2 | The caller
        is inside the shared 30/minute `react` bucket — ONE counter across this path,
        `/recognitions/reactions` and the web bar, so a client can't out-run the limit
        by switching path | `429 rate_limited` (with `Retry-After`) |\n| 3 | The tenant
        has reactions switched on | `403 reactions_disabled` |\n| 4 | The comment
        exists in the caller's business AND its parent recognition is visible to them
        | `404 not_found` |\n\nNote gate 4 is about **reactions**, not comments: turning
        the tenant's\n*comments* switch off stops new comments, it does not freeze
        the reactions\non a thread already there — exactly as the web row behaves.\n\nThe
        success payload carries the comment's **refreshed reaction block**,\nkeyed
        exactly as `reaction_summary` is keyed everywhere else in this\nnamespace,
        so a client patches the comment row it already holds instead of\nre-fetching
        the thread. Two aggregate queries, so a comment with a thousand\nreactions
        costs the same as one with three.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A client-generated key. A retry carrying the same key replays
          the stored response instead of toggling again.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  enum:
                  - "\U0001F44D"
                  - "❤️"
                  - "\U0001F389"
                  - "\U0001F44F"
                  - "⭐"
                  - "\U0001F525"
                  description: The glyph to toggle.
                  example: "\U0001F44F"
      responses:
        '201':
          description: The reaction was ADDED.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '200':
          description: 'The caller already held that emoji, so the toggle REMOVED
            it (`reacted: false`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionReactionState"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`). Nothing is written.
        '404':
          description: No such comment in the caller's business, or one whose parent
            recognition they may not see (`not_found`).
        '422':
          description: "`emoji_required` (blank) or `invalid_emoji` (outside the allowlist).
            `error.details.allowed` carries the accepted glyphs. Nothing is written."
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
            The `Retry-After` header and `error.details.retry_after_seconds` both
            carry the window. Nothing is written.
    delete:
      tags:
      - Recognitions
      summary: Remove your reaction from a comment (idempotent)
      description: |
        Remove the caller's own `emoji` from one comment — the unambiguous half of
        the toggle above.

        **Idempotent.** Removing a reaction that isn't there succeeds with
        `removed: false`, so a client retrying a dropped request never flips the
        reaction back on the way a repeated `POST` would. This is the call to use
        when your UI knows the target state ("off") rather than the transition.

        **Scoped to the caller's own row** — it can never remove somebody else's
        reaction, and it leaves the caller's *other* emojis on the same comment
        alone.

        Same four gates as `POST`, in the same order, including the shared
        30/minute `react` bucket. Answers with the same refreshed reaction block,
        plus `removed`.
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: true
        description: The glyph to remove.
        schema:
          type: string
        example: "\U0001F44F"
      responses:
        '200':
          description: The caller holds no such reaction any more. `removed` says
            whether this call is what removed it.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/RecognitionReactionState"
                - type: object
                  properties:
                    removed:
                      type: boolean
                      description: True when this call deleted a row; false when there
                        was nothing to delete (still a success — the endpoint is idempotent).
                      example: true
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`), or the
            tenant has reactions switched off (`reactions_disabled`).
        '404':
          description: No such comment in the caller's business, or one whose parent
            recognition they may not see (`not_found`).
        '422':
          description: "`emoji_required` or `invalid_emoji`."
        '429':
          description: Past the shared 30/minute `react` bucket (`rate_limited`).
  "/recognitions/posts/{id}/acknowledge":
    post:
      tags:
      - Recognitions
      summary: Settle a recognition celebration (peer recognition)
      description: |
        **"I've celebrated this one."** `GET /api/v1/apps` carries
        `unacknowledged_recognitions` on the Recognitions node — the recognitions
        the caller has RECEIVED and not yet seen. The client fires its confetti on
        app open and then settles each item here, so the next launch is quiet.

        **The implicit half needs no call.** Opening a recognition's detail
        already acknowledges it (`GET /recognitions/posts/{id}`, and the web +
        mobile-web detail screens) — silently, reporting nothing. Use this path
        for the case with no detail view in it: the app celebrated on the launcher
        and the user never tapped through.

        **Silent.** Acknowledging writes one timestamp: no notification, no feed
        change, no `updated_at` bump, nothing the giver or anyone else can
        observe.

        **NOT-THE-RECIPIENT IS A `200`, NOT AN ERROR.** A colleague — or a
        moderator, or the giver — calling this on a recognition they can see gets
        `acknowledged: false` with a diagnostic `reason` and no write. A
        recognition the caller may not SEE still `404`s, exactly as the detail GET
        does, so ids can't be enumerated to discover a private give.

        **Group gives resolve to the caller's OWN row.** One submission naming
        five people is five rows sharing a `recognition_group_id`, and every
        surface collapses them to one card — so four of those five people hold an
        id whose recipient is somebody else. This endpoint stamps the caller's own
        sibling row, and the echoed `id` is the row that was stamped (which may
        differ from the `id` in the path).

        **One item per call, and idempotent.** A repeat call answers
        `acknowledged: true, newly_acknowledged: false` — `newly_acknowledged` is
        the flag a client keys the animation off, so re-opening never
        re-celebrates. There is deliberately no bulk path: a client settling two
        or three celebrations makes two or three calls.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The recognition post's id, scoped to the caller's business.
        schema:
          type: integer
        example: 4471
      responses:
        '200':
          description: 'The acknowledgement state after this call. `acknowledged:
            false` when the caller is not the person recognized — an ordinary no-op,
            not an error.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionAcknowledgement"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such recognition in the caller's business, or one they may
            not see (`not_found`).
  "/recognitions/awards/{id}/acknowledge":
    post:
      tags:
      - Recognitions
      summary: Settle a recognition celebration (award / certificate)
      description: |
        The award half of `POST /recognitions/posts/{id}/acknowledge` — same rule,
        same silence, same `200`-for-a-non-recipient contract, same idempotency.

        Split by type because `RecognitionPost` and `Award` ids collide, exactly as
        the detail GETs are split (`GET /recognitions/posts/{id}` vs
        `GET /recognitions/awards/{id}`). The `type` in the response is the one the
        pending list emits, so the `type` a client is given is the `type` it reads
        back.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The award's id, scoped to the caller's business.
        schema:
          type: integer
        example: 812
      responses:
        '200':
          description: 'The acknowledgement state after this call. `acknowledged:
            false` when the caller is not the person recognized — an ordinary no-op,
            not an error.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecognitionAcknowledgement"
        '401':
          description: Missing or invalid Bearer token.
        '403':
          description: No access to the Recognitions app (`access_denied`).
        '404':
          description: No such award in the caller's business, or one they may not
            see (`not_found`).
  "/company-store/approvals":
    get:
      tags:
      - Company Store
      summary: Redemption approval queue
      description: |
        The Approvals tab: every redemption held for this caller's decision,
        paginated, plus the badge figure a client needs beside the tab.

        **Approvers and store admins only** — see the authorization note on this
        file's other operations and the four gates listed there. A caller who
        holds no queue gets `403 not_an_approver` (or `403 approvals_delegated`
        when a designated approver group has superseded their org-chart route),
        never an empty queue.

        ### The pool

        `Store::RedemptionApprovalService.approval_queue_for` — the ONE
        definition, which picks between two shared pools on the same precedence
        `.can_approve?` itself applies:

        * **store admin** → `.admin_queue_for`: every `pending_approval` order in
          the tenant, at **every** approval tier. Shared verbatim with the admin
          orders page's "Redemptions Awaiting Approval" section, so the two lists
          cannot drift.
        * **everyone else** → `.manager_queue_for`: `pending_approval` orders
          routed to the **manager** tier only, narrowed to the caller's own
          reports (or the whole tenant for an approver-group member). Shared with
          the web queue page, the web dashboard's pending-count banner and the
          native dashboard's Team Approvals widget.

        Three private copies of "the manager queue" is exactly how the dashboard
        once counted holds the queue page then refused, so nothing about either
        pool is re-derived here.

        ### `total_pending` — the badge figure

        The FULL queue depth, independent of `page` / `per_page`, on **every**
        response including the two decision endpoints. So a client renders its tab
        badge from the same call that drew the list, and the badge updates from
        the same response that took a decision — no second request, and no window
        where the badge disagrees with the screen.

        ### Ordering

        `sort=oldest` (the default) is longest-waiting first — the order a queue
        should be worked in, and what the dashboard widget shows; those are the
        requests an approver is holding up. `sort=newest` matches the web table.
        An unrecognised value falls back to the default rather than erroring.

        ### `scope`

        `company` for a store admin or a designated approver-group member (their
        queue spans the tenant) or `team` for a line manager (their own reports).
        The web page renders two different subtitles off exactly this distinction
        — calling a committee's rows "your team" mislabelled every one of them.

        ### Cost

        Flat per page. The requester, their department, the item and both
        attachment chains (requester avatar, product thumbnail) are batched in one
        pass, so a page of 50 rows costs the same queries as a page of 2. The
        per-row `can_approve` costs at most ONE extra query for the whole page —
        and none at all unless the caller is a Company Store app admin who is not
        a business admin, the only persona whose pool is wider than their
        approvable set.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
        description: 1-based page number. `0` / negative / junk is treated as page
          1.
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        description: Rows per page, clamped to 50 server-side — an unbounded `per_page`
          is how one client turns a paginated endpoint into a full-table read. A non-positive
          or junk value falls back to 20. `filters.per_page` reports what was actually
          applied.
      - name: sort
        in: query
        required: false
        schema:
          type: string
          enum:
          - oldest
          - newest
          default: oldest
        description: "`oldest` (default) — longest-waiting first, the order a queue
          is worked in and what the dashboard's Team Approvals widget uses. `newest`
          — the web table's own order. An unrecognised value falls back to `oldest`,
          and `filters.sort` reports which was applied."
      responses:
        '200':
          description: Queue retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  approvals:
                    "$ref": "#/components/schemas/CompanyStoreApprovalQueue"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every native response for badge management.
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `read:company_store`
            — checked first, before any of the gates below), `access_denied` (Company
            Store not accessible to this user), `store_disabled` (the tenant paused
            the store and the caller is not a business admin), `approvals_delegated`
            (a designated approver group has superseded this line manager) or `not_an_approver`
            (this caller has no approval queue)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
  "/company-store/approvals/{id}/approve":
    post:
      tags:
      - Company Store
      summary: Approve a held redemption
      description: |
        Releases the hold for fulfillment and notifies the employee — the native
        equivalent of the web queue's **Approve** button.

        Runs `Store::RedemptionApprovalService.approve!`, the same call the web
        button makes, which flips the order to `pending`, records the approver on
        the order, dispatches fulfillment (gift cards fulfill inline; physical /
        print-on-demand enqueue to the provider) and notifies the redeemer
        in-app/push. **The points stay spent** — `points_refunded` is `0`.

        Only for a hold in **this** caller's own queue: an `id` they cannot even
        see answers `404`, not `403`, because from this endpoint's point of view
        it is not an approvable request at all. A hold they CAN see but may not
        decide — the admin branch's `can_approve: false` rows — answers `403
        not_an_approver`.

        The response carries the recomputed `total_pending`, so a client's tab
        badge updates from the same call that took the decision.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The StoreOrder id, as reported by each queue row's `id` (and
          by the dashboard widget's `team_approvals.requests[].id`) — the same identifier
          the web queue's own buttons post to. Digits only.
      responses:
        '200':
          description: Approved and released for fulfillment
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalDecisionResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`),
            `access_denied`, `store_disabled`, `approvals_delegated` or `not_an_approver`
            — see the queue endpoint. `not_an_approver` also covers the per-ROW refusal:
            the caller holds a queue and this hold is in it, but they may not decide
            THIS one (`can_approve: false`)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '404':
          description: "`not_found` — that redemption isn't in your approval queue
            (it was never held, it is held at a different tier, or it belongs to somebody
            outside your team)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '409':
          description: "`decision_failed` — the hold moved out from under you between
            drawing the queue and deciding (already approved or declined by someone
            else, or re-tiered so it now routes to a different approver). The request
            was well-formed and you ARE an approver, so a client should refresh the
            queue rather than retry. `already_decided` — the hold is no longer waiting
            for approval at all (the employee withdrew it, or it was approved or declined
            since the queue was drawn); refresh the queue."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
  "/company-store/approvals/{id}/decline":
    post:
      tags:
      - Company Store
      summary: Decline a held redemption
      description: |
        Declines the redemption, **refunds the held points to the employee** and
        restocks the item — the native equivalent of the web queue's **Decline
        redemption** modal.

        Runs `Store::RedemptionApprovalService.reject!`, the same call the web
        modal makes: the order is cancelled (which returns the points to the
        employee's wallet, and to the team store budget it was drawn from if any,
        and increments the item's inventory back) and the employee is notified.
        `points_refunded` reports what went back.

        `reason` is **optional but employee-facing**: the redeemer sees it in
        their notification, so send the one the approver typed. With none, the
        service records a generic "not approved by &lt;approver&gt;". A
        whitespace-only reason is treated as none given. (The web modal requires a
        reason before enabling its submit button; the native reject sheet has
        none, so the server accepts both.)

        Only for a hold in **this** caller's own queue — same `403` / `404` /
        `409` semantics as `approve`.

        Delete-level (owner decision 2026-10-04): requires the `destructive:company_store`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The StoreOrder id, as reported by each queue row's `id` (and
          by the dashboard widget's `team_approvals.requests[].id`) — the same identifier
          the web queue's own buttons post to. Digits only.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why it was declined. The EMPLOYEE sees this — send
                    the approver's own words. Optional; blank/absent records a generic
                    "not approved by <approver>".
                  example: This item is out of budget for this quarter
      responses:
        '200':
          description: Declined; points refunded and inventory restocked
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalDecisionResponse"
        '401':
          description: Missing or invalid token
        '403':
          description: "`insufficient_permissions` (the token lacks `write:company_store`),
            `access_denied`, `store_disabled`, `approvals_delegated` or `not_an_approver`
            — see the queue endpoint. `not_an_approver` also covers the per-ROW refusal:
            the caller holds a queue and this hold is in it, but they may not decide
            THIS one (`can_approve: false`)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '404':
          description: "`not_found` — that redemption isn't in your approval queue."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
        '409':
          description: "`decision_failed` — already decided, or re-routed to a different
            approver. `already_decided` — no longer waiting for approval (withdrawn,
            approved or declined since the queue was drawn)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CompanyStoreApprovalError"
  "/libraries/list":
    get:
      tags:
      - Libraries
      summary: All Libraries (index)
      description: |
        The native-client mirror of the web **All Libraries** index
        (`Apps::LibrariesController#show`), shaped to the Libraries design
        mockup's home screen. One row carries everything a card needs — cover,
        mark, kind, both counts, last-updated and the two capability flags — so
        rendering a screen costs ONE call and no per-row follow-ups.

        Both surfaces resolve the list through the same `Libraries::IndexQuery`,
        so the API and the web page cannot drift on which libraries are visible,
        what "Admin order" means, or where disabled libraries sit.

        ### What the caller sees

        * **A Libraries admin** (business admin, super admin, or a per-app
          Libraries admin) sees every library in the tenant, **disabled ones
          included** — they are badged, not hidden, because this is the surface
          that offers Enable. `can_disable` is `true`.
        * **A place-limited Libraries admin** (a Libraries admin grant limited
          to places or departments) sees what a reader sees, plus the libraries
          whose audience sits inside their reach, disabled ones included.
          `can_disable` is `true`; each card's own `can_disable` is true only
          for a library in their reach.
        * **Everyone else** sees the ENABLED libraries whose audience admits
          them: `visibility: all_users` libraries, plus any whose audience rules
          match them by user, department, group, location, job family, job title,
          organizational role or platform role — and a DISABLED library only
          when they can manage it (its creator or a named `manager`), badged.
          `can_disable` is `false`.

        Visibility narrows the rows, `filter_counts` AND `meta.total_count`
        together, so every chip badge counts exactly what clicking it returns.

        ### Ordering

        **Disabled libraries are ALWAYS LAST, under every sort** — they are
        admin-only, and a hidden library taking a slot among the live ones pushes
        real content down the page. The requested `sort` orders rows *within* the
        enabled and disabled groups. The rule lives in the SQL `ORDER BY`, so it
        holds across page boundaries: disabled rows land on the last page, never
        at the bottom of page 1.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: 'Which chip is selected. `disabled` is for Libraries admins and
          place-limited admins (the libraries in their reach) — a disabled library
          is invisible to everyone else, so a member requesting it falls back to `all`
          (200, with `active_filter: "all"`) rather than being refused. An unknown
          value also falls back to `all`. Read `available_filters` to know which chips
          to draw.'
        schema:
          type: string
          enum:
          - all
          - disabled
          default: all
      - name: sort
        in: query
        required: false
        description: |
          The order to list in — the mockup's three options:

          * `admin` — **Admin order**: the order admins set in the web Reorder
            dialog (the stored `position`), then name.
          * `az` — **A → Z**: by name ascending, case-insensitively.
          * `recent` — **Recently updated**: newest `updated_at` first. This is
            the same timestamp the card's `updated_at` carries, so the sort and
            the label can never disagree.

          `updated` is accepted as a DEPRECATED alias of `recent` (the mobile
          prototype's spelling); the response always echoes the **canonical**
          key in `sort`, so a client can tell which order it actually got. An
          unknown value falls back to `admin`.
        schema:
          type: string
          enum:
          - admin
          - az
          - recent
          default: admin
      - name: page
        in: query
        required: false
        description: 1-based. A missing, zero, negative, non-numeric or collection-shaped
          value is 1. A page past the end is an empty 200, not an error.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page, capped at 50. A value that does not parse as a
          positive integer (`0`, `-3`, `abc`, `?per_page[]=5`) is treated as ABSENT
          and falls back to 20 — deliberately NOT floored to 1, which would hand a
          malformed client a one-row-per-page walk of the whole list.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Library list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - libraries
                - active_filter
                - sort
                - available_filters
                - available_sorts
                - filter_counts
                - can_disable
                - meta
                properties:
                  libraries:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LibraryCard"
                  active_filter:
                    type: string
                    enum:
                    - all
                    - disabled
                    description: The filter actually applied — which is not always
                      the one requested (an unknown value, or `disabled` from a member,
                      resolves to `all`).
                    example: all
                  sort:
                    type: string
                    enum:
                    - admin
                    - az
                    - recent
                    description: The order actually applied, always as the CANONICAL
                      key (a `?sort=updated` request answers `recent`).
                    example: admin
                  available_filters:
                    type: array
                    description: The chips THIS caller may select — `["all", "disabled"]`
                      for a Libraries admin, `["all"]` for everyone else. Draw the
                      chip row from this rather than hard-coding the admin-only rule.
                    items:
                      type: string
                    example:
                    - all
                    - disabled
                  available_sorts:
                    type: array
                    description: The three orders, in the order the menu shows them.
                    items:
                      type: string
                    example:
                    - admin
                    - az
                    - recent
                  filter_counts:
                    type: object
                    description: Total per available filter, for the chip badges.
                      Counted over the SAME visible set the rows come from, so each
                      badge equals the `meta.total_count` that filter returns. A non-admin
                      payload OMITS the `disabled` key entirely (not a zero) — that
                      chip was never offered.
                    properties:
                      all:
                        type: integer
                        example: 8
                      disabled:
                        type: integer
                        description: Admin only. Absent for other callers.
                        example: 2
                  can_disable:
                    type: boolean
                    description: 'Whether this caller may Disable a library — the
                      gate behind the row menu''s Disable option and behind `POST
                      /libraries/{id}/disable`: a Libraries admin, or a place-limited
                      one, whose credential holds `destructive:libraries` (a session
                      always does). App-wide, not per-library: each card''s own `can_disable`
                      answers for that library and the verb that row offers.'
                    example: true
                  meta:
                    "$ref": "#/components/schemas/LibraryListMeta"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:libraries`;
            or `access_denied` — the Libraries app is not enabled for the tenant,
            or this user is outside the app's audience."
  "/libraries/search":
    get:
      tags:
      - Libraries
      summary: Search libraries, categories and items
      description: |
        The native-client mirror of the web search on the **All Libraries** page
        (`Apps::LibrariesController#show` with `?q=`), shaped to the mobile
        prototype's inline results. Both surfaces resolve through the same
        `Libraries::SearchQuery`, so the API and the web page cannot drift on
        what matches, what ranks first, or whose libraries are searched.

        ### What is searched

        Three record types, each on its own fields — echoed back in
        `searched_fields` so a client need not hard-code them:

        | Type | Fields |
        |------|--------|
        | Libraries  | `name`, `description` |
        | Categories | `name` (the table has no description) |
        | Items      | `title`, `description` |

        An item is **not** matched by its library's or its category's name. A
        query like `company` therefore returns the library *Company Policies*
        plus the items whose own title or description says "company" — not all
        28 items that happen to live in it, which would be rows with nothing in
        them to show for the match.

        ### Ranking

        Every row carries the `search_score` it was ordered by:

        * library — 3 per `name` hit + 1 per `description` hit, **+ 6**
        * category — 3 per `name` hit, **+ 3**
        * item — 2 per `title` hit + 1 per `description` hit

        The two bumps put places above items and libraries above categories,
        which is the order the mockups present them in. **Multi-term is OR**:
        `safety harness` matches rows containing either word, ranked by how many
        they match.

        ### What the caller sees

        Results are drawn from the libraries this caller may see, and
        **DISABLED LIBRARIES ARE EXCLUDED FOR EVERYONE** — including admins.
        A disabled library is hidden from everyone but an admin and excluded
        from search; the All Libraries index is where an admin finds it (badged,
        with the Enable control). Everyone else sees `visibility: all_users`
        libraries plus any whose audience rules match them.

        ### Pagination

        **Only `items` paginates.** Libraries and categories are bounded by the
        tenant's own structure (tens of rows) and every mockup shows all of them
        above the items, so `meta` describes the ITEM page while `counts`
        reports all three totals and `counts_total` their sum.

        **And because they do not paginate, `libraries` and `categories` are
        sent on PAGE 1 ONLY.** From `page=2` on, both are `[]` — not because
        nothing matched, but because you already have them. Re-sending them per
        item page was pure repetition (measured 2026-09-05: `?q=a&per_page=1`
        returned byte-identical `libraries` and `categories` blocks on pages
        1, 2 and 3, ~98% of each page's payload), and a client that APPENDS the
        two arrays as it pages — the natural shape for the infinite scroll this
        `meta` exists to drive — rendered every library once per page.

        **How to tell "not resent" from "genuinely zero":** read `counts`, which
        is computed off the match relations rather than off these arrays and so
        carries all three totals on EVERY page. `libraries: []` with
        `counts.libraries > 0` means "already sent on page 1"; with
        `counts.libraries == 0` it means nothing matched. Never infer either
        total from an array's length.

        ### Truncation is stated, never silent

        The two unpaginated lists are capped at **100 rows each**
        (`Libraries::SearchQuery::MAX_PLACE_RESULTS`) so a one-character `q`
        cannot be a single-request dump of every library and category name in
        the tenant. `libraries_truncated` and `categories_truncated` say when a
        cap actually bit, so a client is never handed a partial list it thinks
        is complete — the same contract, and the same key names, as the sibling
        `GET /libraries/{id}` (`categories_truncated`, `items_truncated`).

        Both flags are derived from the CEILING, not from the array length:
        `counts.<type> > 100`. That is why they stay honest on page 2, where the
        arrays are empty by design and a length comparison would claim
        truncation for every two-library search.

        ### Not offered

        The mobile facet sheet (library / item type / updated / review) is not
        implemented: the web search this mirrors has no facets, and two of the
        four have nothing behind them here — `review` is content governance (a
        different model with its own surface) and `updated` is a bucket the
        prototype derives from a hand-written string. A filter that silently
        matched nothing would be worse than none.

        Item rows DO carry the caller's own bookmark state — `bookmarked` and
        the `bookmark` sub-object — resolved in one query for the page, never
        one per row. (Until 2026-09-05 they did not: the serializer's `bookmark`
        argument defaulted to nil, so every search row reported
        `bookmarked: false` while `GET /libraries/bookmarks` and
        `GET /libraries/{id}` said true for the same item in the same minute,
        and a client rendering its kebab from this flag offered "Save" for an
        item already saved.)
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        description: The search string. Whitespace-separated terms are OR'd and scored
          by how many match; terms beyond the tenth are dropped (the response's `terms`
          reports what was actually used). `%`, `_` and `\` are searched literally,
          not as wildcards. Blank or whitespace-only is a 400 — a client asking for
          nothing is a client with a bug, and an empty 200 would hide it.
        schema:
          type: string
          minLength: 1
          example: safety harness
      - name: sort
        in: query
        required: false
        description: |
          * `relevance` — by `search_score` descending, then name/title. The
            default, and the only order the mockups show.
          * `recent` — newest `updated_at` first, within each record type.

          `updated` is accepted as an alias of `recent` (the mobile
          prototype's spelling); the response echoes the **canonical** key in
          `sort`. An unknown value falls back to `relevance`.
        schema:
          type: string
          enum:
          - relevance
          - recent
          default: relevance
      - name: page
        in: query
        required: false
        description: |-
          1-based, and applies to `items` only. A missing, zero, negative, non-numeric or collection-shaped value is 1. A page past the end is an empty `items` array with a 200, not an error.
          ANY page but the first also answers `libraries: []` and `categories: []` — those two lists do not paginate and are therefore sent once, on page 1 (see Pagination above). `counts` is unaffected and reports all three totals on every page, which is how a client tells "not resent" from "nothing matched".
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Items per page, capped at 50. A value that does not parse as
          a positive integer (`0`, `-3`, `abc`) is treated as ABSENT and falls back
          to 20 — deliberately NOT floored to 1.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                required:
                - query
                - terms
                - sort
                - available_sorts
                - searched_fields
                - counts
                - counts_total
                - libraries
                - categories
                - libraries_truncated
                - categories_truncated
                - items
                - meta
                properties:
                  query:
                    type: string
                    description: The query as searched — the request's `q`, trimmed.
                    example: safety harness
                  terms:
                    type: array
                    description: 'The terms actually used: whitespace-split, de-duplicated
                      case-insensitively, capped at ten. A client highlighting matches
                      should use THESE, not its own split of `query`.'
                    items:
                      type: string
                    example:
                    - safety
                    - harness
                  sort:
                    type: string
                    enum:
                    - relevance
                    - recent
                    description: The order actually applied, always as the CANONICAL
                      key (a `?sort=updated` request answers `recent`).
                    example: relevance
                  available_sorts:
                    type: array
                    items:
                      type: string
                    example:
                    - relevance
                    - recent
                  searched_fields:
                    type: array
                    description: The fields this search covered, `group.column`. Read
                      it rather than hard-coding the list — it comes from the same
                      constants the SQL is built from.
                    items:
                      type: string
                    example:
                    - libraries.name
                    - libraries.description
                    - categories.name
                    - items.title
                    - items.description
                  counts:
                    "$ref": "#/components/schemas/LibrarySearchCounts"
                  counts_total:
                    type: integer
                    description: |-
                      Every match across all three types — the sum of `counts`, and the number the web results head with ("18 results"). NOT the item page's total, which is `meta.total_count`.
                      NAMED `counts_total`, NOT `total_count`, since 2026-09-05. Both names lived in this one body meaning different numbers: measured live, `?q=brand` answered root 3 / `meta.total_count` 1, and `?q=a` answered root 227 / meta 205 — so a client reading "total_count" got a different answer depending on how deep it looked, and a generic paginator keyed on the root value asked for a 12th page of an 11-page list. A client written against the old name must read `counts_total` (or sum `counts` itself).
                    example: 18
                  libraries:
                    type: array
                    description: |-
                      Matching libraries, ranked — the same card the index returns, plus `search_score`.
                      **PAGE 1 ONLY.** This list does not paginate, so it is sent once and every later page answers `[]`. An empty array is NOT a statement about matches: read `counts.libraries`, which is present on every page, to tell "already sent" from "nothing matched". Capped at 100 rows, with `libraries_truncated` saying when the cap bit.
                    items:
                      allOf:
                      - type: object
                        description: One row of the All Libraries index. Every field
                          the mockup's card renders, with the same fallbacks the web
                          card applies — so the two surfaces cannot drift on an icon,
                          a colour or a type label.
                        required:
                        - id
                        - title
                        - description
                        - image
                        - banner_fill
                        - icon
                        - color
                        - library_type
                        - library_type_label
                        - categories_count
                        - items_count
                        - updated_at
                        - enabled
                        - can_disable
                        - link
                        properties:
                          id:
                            type: integer
                            example: 42
                          title:
                            type: string
                            description: The library's name.
                            example: Company Policies
                          description:
                            type: string
                            nullable: true
                            description: The card's blurb. `null` (never `""`) when
                              the library has none.
                            example: Every published company policy, grouped by the
                              team that owns it.
                          image:
                            type: string
                            nullable: true
                            description: ABSOLUTE URL of the library's banner image,
                              or `null` when it has none — in which case paint `banner_fill`.
                              Absolute because a native client cannot resolve a host-relative
                              path.
                            example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf.../banner.png
                          banner_fill:
                            type: string
                            description: 'The CSS gradient the web card paints behind
                              a library with no banner image. Sent whole rather than
                              derived client-side: the light stop is a hand-picked
                              per-preset value, so `color` alone is not enough to
                              reproduce it.'
                            example: linear-gradient(110deg,#3f5372,#7c8fb0)
                          icon:
                            type: string
                            description: Font Awesome class for the mark on the banner.
                              Falls back to `fas fa-book-open` — the same default
                              the web card uses — so this is never blank.
                            example: fas fa-file-shield
                          color:
                            type: string
                            description: The library's hex colour, driving both the
                              icon tint and the banner fill. Falls back to the app
                              default (`#2f64b1`), so this is never blank and never
                              a non-hex value.
                            example: "#3f5372"
                          library_type:
                            type: string
                            enum:
                            - mixed_content
                            - images_and_videos
                            description: The library kind, for client logic.
                            example: mixed_content
                          library_type_label:
                            type: string
                            description: The display wording the banner prints. Provided
                              because `titleize` gets it wrong — `images_and_videos`
                              reads "Images & Videos", not "Images And Videos".
                            example: Mixed Content
                          categories_count:
                            type: integer
                            description: Number of categories in this library (the
                              folder icon on the card). Computed by a scalar subquery,
                              so it costs nothing per row.
                            example: 6
                          items_count:
                            type: integer
                            description: Number of items across all of this library's
                              categories (the document icon on the card). Also a scalar
                              subquery.
                            example: 28
                          updated_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: ISO8601. The same timestamp `sort=recent`
                              orders on and the card renders as "Updated N ago".
                            example: '2026-08-29T06:32:39Z'
                          enabled:
                            type: boolean
                            description: '`false` for a disabled library — draw the
                              "Disabled" badge and grey the row. Only someone who
                              can manage the library (a Libraries admin, a place-limited
                              one in reach, its creator or a named `manager`) ever
                              receives a `false` here.'
                            example: true
                          can_disable:
                            type: boolean
                            description: 'Whether this caller may take the availability
                              verb THIS row offers: Disable on an enabled library
                              (`POST /libraries/{id}/disable`, which asks a token
                              `destructive:libraries`) or Enable on a disabled one
                              (`POST /libraries/{id}/enable`, which asks `write:libraries`).
                              False for anyone who is not a Libraries admin or a place-limited
                              Libraries admin with this library in reach. So a token
                              holding `write:libraries` alone sees true only on disabled
                              rows.'
                            example: true
                          link:
                            type: string
                            nullable: true
                            description: ABSOLUTE web URL of the library — for the
                              row menu's Copy link, and for opening the library in
                              a webview.
                            example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/42
                      - type: object
                        properties:
                          search_score:
                            type: integer
                            description: The score this row was ranked by.
                            example: 10
                  categories:
                    type: array
                    description: 'Matching categories, ranked. **PAGE 1 ONLY**, on
                      the same terms as `libraries` above: `[]` from page 2 on, with
                      `counts.categories` as the total that stays truthful on every
                      page. Capped at 100 rows, with `categories_truncated` saying
                      when the cap bit.'
                    items:
                      "$ref": "#/components/schemas/LibrarySearchCategory"
                  libraries_truncated:
                    type: boolean
                    description: |-
                      True when more than 100 libraries matched (`Libraries::SearchQuery::MAX_PLACE_RESULTS`) and the `libraries` array therefore holds only the first 100. Present on EVERY page, and derived from `counts.libraries > 100` rather than from the array's length — which is what keeps it honest on page 2, where the array is empty by design.
                      Read it. It exists so a client is never silently handed a partial list; nothing else in the response says the list was clipped.
                    example: false
                  categories_truncated:
                    type: boolean
                    description: The same flag for `categories`, against the same
                      100-row ceiling and the same `counts.categories > 100` derivation.
                    example: false
                  items:
                    type: array
                    description: One page of matching items, ranked. The same card
                      `GET /libraries/bookmarks` returns (same serializer), plus `search_score`.
                      `bookmarked` and `bookmark` carry the CALLER'S OWN saved state
                      here, resolved in one query for the page — they are not the
                      constant false/null this endpoint returned before 2026-09-05.
                    items:
                      allOf:
                      - type: object
                        description: One saved library item — everything a row, its
                          3-dot sheet and its details panel need, from one call.
                        required:
                        - id
                        - title
                        - item_type
                        - item_type_label
                        - icon_type
                        - icon
                        - breadcrumb
                        - status
                        - open_mode
                        - bookmarked
                        properties:
                          id:
                            type: integer
                            description: The LIBRARY ITEM id — what the bookmark write
                              endpoints take.
                            example: 412
                          title:
                            type: string
                            example: Remote Work Policy
                          description:
                            type: string
                            nullable: true
                          item_type:
                            type: string
                            description: The stored item kind. Branch logic on this,
                              not on `item_type_label`.
                            enum:
                            - simple_link
                            - file
                            - form
                            - survey
                            - wiki
                            - post
                            - image
                            - video
                            example: file
                          item_type_label:
                            type: string
                            description: |-
                              Display label, EXTENSION-specific where the app can tell ("PDF", "Word", "Excel") and type-level otherwise ("Link", "Form", "Wiki"). The same label the web row prints.
                              NAMED `item_type_label`, NOT `type_label`, since 2026-09-05: the column is `item_type`, every other label in this repo is `<prefix>_type_label` (including `library_type_label` on the library card and `item_type_label` on `GET /libraries/{id}`), and the bare spelling meant one concept arrived under two names depending on which Libraries endpoint a client called.
                            example: PDF
                          icon_type:
                            type: string
                            description: |-
                              WHAT KIND OF STRING `icon` IS — the discriminator, because the glyph cannot carry its own domain. `default` and `custom` mean `icon` is a Font Awesome CLASS; `emoji` means `icon` is a raw emoji GRAPHEME.
                              Read this before you render. A client that maps `icon` to an icon font unconditionally draws tofu for every emoji row, and one that prints it as text shows "fas fa-file-pdf" for the rest. No tenant holds an emoji row today, but Load Sample Data seeds three, so this is one button press from live rather than theoretical.
                              Nullable, because the column is: `library_items.icon_type` has no NOT NULL and the model's enum takes `allow_nil`, so an explicitly cleared row is a legal state. Treat null as `default`.
                            enum:
                            - default
                            - emoji
                            - custom
                            -
                            nullable: true
                            example: default
                          icon:
                            type: string
                            description: The glyph — a Font Awesome class or an emoji
                              grapheme, per `icon_type` above. Extension-specific
                              where known; overridden by the item's own icon when
                              an admin set one.
                            example: fas fa-file-pdf
                          icon_color:
                            type: string
                            description: Hex glyph colour.
                            example: "#c0392b"
                          icon_background:
                            type: string
                            description: Fill for the plate behind the glyph. Hex,
                              or an `rgba()` string when derived from an admin-chosen
                              item colour.
                            example: "#fbe7e5"
                          format:
                            type: string
                            nullable: true
                            description: Lowercase file extension without the dot,
                              or null when the item has nothing to infer one from
                              (a form, a wiki page, a bare link).
                            example: pdf
                          library:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 7
                              name:
                                type: string
                                example: Company Policies
                              icon:
                                type: string
                                example: fas fa-shield-halved
                              color:
                                type: string
                                example: "#3478f6"
                              enabled:
                                type: boolean
                                description: False only for a Libraries administrator
                                  looking at a disabled library's saved item — grey
                                  the row and badge it "Disabled".
                                example: true
                          category:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 21
                              name:
                                type: string
                                example: Leave & Time Off
                              position:
                                type: integer
                                example: 0
                          breadcrumb:
                            type: string
                            nullable: true
                            description: "`Library › Category`, pre-joined with the
                              separator the design uses."
                            example: Company Policies › Leave & Time Off
                          status:
                            type: string
                            description: "`available` — opens normally. `inactive`
                              — the linked record was unpublished/deactivated after
                              it was linked, OR this reader may not open it; show
                              the details panel, not a link. `unavailable` — the linked
                              record is gone; a library manager must relink or remove
                              it."
                            enum:
                            - available
                            - inactive
                            - unavailable
                            example: available
                          status_label:
                            type: string
                            nullable: true
                            description: The badge text the web renders beside the
                              row ("Inactive", "Source unavailable"), or null when
                              the item is healthy.
                            example:
                          url:
                            type: string
                            nullable: true
                            description: |-
                              Absolute destination — where a tap goes. NULL whenever `open_mode` is `unavailable`, including for an item this particular reader may not open even though a link exists for someone else.
                              ALSO NULL when `open_mode` is `form`: a form is opened BY ID, from `form_id`. Branch on `open_mode`, never on which of the two is set.
                          form_id:
                            type: integer
                            nullable: true
                            description: 'The id of the linked form — the locator
                              that replaced a form item''s web URL, for `open_mode:
                              form` only. Open your own form screen with it. Null
                              for every other type, and null for a form this reader
                              may not open (same rule `url` follows: `status` / `status_label`
                              say why).'
                            example:
                          copy_link_url:
                            type: string
                            nullable: true
                            description: 'What "Copy link" puts on the clipboard:
                              the absolute destination. The same value as `url` for
                              every type EXCEPT a form, where it is the card''s only
                              URL — the clipboard''s job is to produce something a
                              person can paste, and a form id pastes into nothing.
                              Null whenever `open_mode` is `unavailable`.'
                          link_url:
                            type: string
                            nullable: true
                            description: The source URL an admin stored on the item,
                              which the details panel prints on its own row. Distinct
                              from `url`, which is where a tap goes.
                          opens_in:
                            type: string
                            nullable: true
                            description: |-
                              The item's configured link target — what the web anchor's `target` reads. `GET /libraries/{id}` carries the same column as `open.target`.
                              NULLABLE, and a typed client must model it as optional. `library_items.link_target` is `t.string default: "new_tab"` with no NOT NULL, the model's enum takes `allow_nil`, and the item form deliberately submits an empty value — which casts back to nil — for every item type whose "Open link in" panel is never shown, so an unanswered question stays unanswered rather than being answered "New tab" on the admin's behalf. No row holds null today; the first one would crash a client with a non-optional String here.
                            enum:
                            - new_tab
                            - current_tab
                            -
                            example: new_tab
                          open_mode:
                            type: string
                            description: How to open it, and which of `url` / `form_id`
                              carries the locator. `form` — a form this app hosts;
                              route to your own form screen using `form_id` (`url`
                              is null). `external` — an off-platform http(s) URL;
                              hand it to the system browser. `preview` — a file this
                              app serves; open the file preview or the media viewer.
                              `in_app` — a path inside the platform (a wiki page,
                              a survey, a document record). `unavailable` — nothing
                              to open; show the details panel and `status_label` says
                              why.
                            enum:
                            - form
                            - external
                            - preview
                            - in_app
                            - unavailable
                            example: preview
                          download_url:
                            type: string
                            nullable: true
                            description: Absolute URL for the same blob served as
                              an attachment, or null when the item has no file to
                              save (a link, a form, a wiki page, or a document record
                              that stores no blob of its own). Omit the Download row
                              from the sheet when null rather than showing it inert.
                          captions:
                            type: string
                            nullable: true
                            description: The listing's captions marker for a VIDEO,
                              the state the web and phone listings draw their "CC"
                              chip from. `available` — the video has a caption track
                              (draw "CC", spoken label "Captions available"); `coming`
                              — a track is being generated (draw "Captions coming").
                              Null for every other item, and for a video with neither.
                          captions_url:
                            type: string
                            nullable: true
                            description: Absolute URL of that video's WebVTT caption
                              track — fetch it with the same bearer token (GET /libraries/{library_id}/items/{id}/captions,
                              `text/vtt`) and show the cues while the video plays.
                              Set exactly when `captions` is `available`; null otherwise.
                              The web player's `<track>` route answers only a browser
                              session, so a native client must use this URL.
                          media:
                            type: object
                            nullable: true
                            description: The item's own uploaded file. Null when it
                              has none.
                            properties:
                              filename:
                                type: string
                                example: remote-work.pdf
                              content_type:
                                type: string
                                example: application/pdf
                              byte_size:
                                type: integer
                                example: 1153434
                              size_label:
                                type: string
                                description: Pre-formatted, matching what the details
                                  panel prints.
                                example: 1.1 MB
                              width:
                                type: integer
                                nullable: true
                                description: |-
                                  Pixel width, ALWAYS an integer, and null — never zero — for a blob Active Storage has not analyzed yet.
                                  Integer is a coercion, not a passthrough: the two analyzers disagree on type. The image analyzer writes vips' Integer while the VIDEO analyzer writes `Float(video_stream["width"])` by design, and this key used to ship whichever the row happened to hold — 2,000 image blobs as Integer, 149 video blobs as Float, under one key. Since 2026-09-05 both are coerced, which is safe because a pixel count is a whole number in either analyzer.
                                example: 2400
                              height:
                                type: integer
                                nullable: true
                                description: Pixel height. Integer on the same terms
                                  as `width`.
                                example: 1600
                              dimensions:
                                type: string
                                nullable: true
                                description: '`width × height`, or null when either
                                  is unknown. Integral on both sides — it read "1920.0
                                  × 1080.0" for every video until the coercion above
                                  landed.'
                                example: 2400 × 1600
                              duration_seconds:
                                type: number
                                nullable: true
                                description: Video length. Null until background analysis
                                  has run on a freshly uploaded file — that is a real
                                  "not yet known", not a zero.
                                example: 92.4
                              duration_label:
                                type: string
                                nullable: true
                                description: "`m:ss` or `h:mm:ss`."
                                example: '1:32'
                              url:
                                type: string
                                description: Absolute URL for the blob served INLINE
                                  (preview / viewer).
                          created_by:
                            type: object
                            nullable: true
                            description: 'Who added the item — the details panel''s
                              "Added by" byline. NAMED `created_by`, NOT `added_by`,
                              since 2026-09-05: those are the names the columns actually
                              have, and the names every other payload in this namespace
                              uses (`GET /libraries/{id}` for both the item and the
                              library). `added_by` / `added_at` were the only two
                              occurrences of that spelling in the whole `/api/v1`
                              surface — 2 sites against 254 — so a client decoding
                              "who added this, and when" needed two field names depending
                              on the endpoint.'
                            properties:
                              id:
                                type: integer
                                example: 88
                              name:
                                type: string
                                example: Neha Kulkarni
                          created_at:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the ITEM was added to the library. Not
                              to be confused with `bookmark.created_at`, which is
                              when THIS CALLER saved it — the key this list is ordered
                              by.
                          updated_by:
                            type: object
                            nullable: true
                            description: Null until somebody edits the item.
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                          updated_at:
                            type: string
                            format: date-time
                            nullable: true
                          can_manage:
                            type: boolean
                            description: May this caller edit / move / delete items
                              in this library. Gates the 3-dot sheet's Edit, Move
                              and Delete rows; it follows the library's management
                              level, not the caller's role alone.
                            example: false
                          can_delete:
                            type: boolean
                            description: 'May this caller delete the item from the
                              3-dot sheet: `can_manage` AND the token holds `destructive:libraries`,
                              which `DELETE /libraries/{library_id}/items/{id}` asks.
                              Draw Delete off this flag; Edit and Move stay on `can_manage`
                              (web handoffs).'
                            example: false
                          bookmarked:
                            type: boolean
                            description: Always true on this endpoint. Present so
                              one card type serves every list.
                            example: true
                          bookmark:
                            type: object
                            nullable: true
                            description: The caller's own bookmark row.
                            properties:
                              id:
                                type: integer
                                description: The BOOKMARK id (not the item id). Useful
                                  for local ordering; the bookmark write endpoints
                                  address the ITEM, not this.
                                example: 9021
                              note:
                                type: string
                                nullable: true
                                description: The caller's own note, authored on the
                                  web /bookmarks page.
                                example: Read before the audit
                              created_at:
                                type: string
                                format: date-time
                                description: When it was saved — the sort key of this
                                  list.
                      - type: object
                        properties:
                          search_score:
                            type: integer
                            description: The score this row was ranked by.
                            example: 4
                  meta:
                    "$ref": "#/components/schemas/LibrarySearchItemPagination"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    type: object
                    description: Standard response metadata
                    additionalProperties: true
                    properties:
                      request_id:
                        type: string
                        description: Unique request identifier
                      generated_at:
                        type: string
                        format: date-time
                        description: Timestamp when the response was generated
                      execution_time_ms:
                        type: number
                        description: Server processing time in milliseconds
                      total_count:
                        type: integer
                        description: Total number of items
                      total_pages:
                        type: integer
                        description: Total number of pages
                      current_page:
                        type: integer
                        description: Current page number
                      per_page:
                        type: integer
                        description: Number of items per page
        '400':
          description: "`q` was missing, blank or whitespace-only (`invalid_request`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token lacks `read:libraries` (`insufficient_permissions`),
            or the Libraries app is not accessible to this caller — not enabled for
            the tenant, or the caller is outside the app's audience (`access_denied`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/search":
    get:
      tags:
      - Search
      summary: Search everything the caller can see (the /apps/search page as data)
      description: |
        The same engine, ranking, live answers, filters and permissions as the
        web results page (`/apps/search`), so a native app, a chat command or an
        MCP client never ranks or leaks differently from the browser. Every row
        is already scoped to the caller by the engine.

        ### Without `q` — the page before you type

        Answers `home`: what is waiting on the caller (`for_you`, sections of
        rows with a due date), their saved searches, and their recent searches
        (last 30 days, their own only). No `sections`.

        ### With `q`

        A one-character `q` is refused with `400 invalid_query`, as the page
        refuses to run it. Otherwise the body carries, in the page's order: an
        optional `live_answer` (a structured fact), the `best_match`, the ranked
        `top` rows, `shortcuts` (pages to go to), `experts` (people who can
        help), the `sections` by area, and `external_sections` (Enterprise
        connected sources). `did_you_mean` suggests a spelling when the page is
        empty or thin.

        ### Filters — the page's Filter control

        | Param | Web label | Values | Keeps |
        |-------|-----------|--------|-------|
        | `in` | In | a section key from `filter_options.in` | that area only (the engine runs only its leg) |
        | `since` (alias `when`) | When | `30d`, `90d`, `year` | rows dated after the cutoff; an undated row hides, except navigation sections (people, settings, apps) |
        | `mine` | Only mine | `1` (or `true`) | the caller's own rows |

        Filters combine. Under any filter `shortcuts` is empty and there is no
        `did_you_mean`; under `in` or `mine` there are no external sources and
        no experts. `filters` echoes what was applied (always as `since`, never
        `when`); an unknown `in` or `since` value is ignored, not an error.
        `filter_options` is the control itself — every choice with its
        `selected` state — and `active_filters` the chips, each with the params
        that remove it.

        ### Scope ceiling

        Takes `read:search` (what the MangoApps app sign-ins grant) or
        `read:my_data` (an AI connector's default grant). Either reaches every
        area except those needing their own scope:
        `password_manager` (`read:password_manager`), `my_hr_files` and
        `documents` (`read:hr_file_manager`), `compensation_management`
        (`read:compensation`), `mango_cs` (`read:mango_cs`), `source`
        (`read:source`), `field_service` (`read:field_service_suite`),
        `appointment_scheduler` (`read:appointment_scheduler`), `rfp_desk`
        (`read:rfp_response`), `lone_worker_alert` (`read:lone_worker`),
        `payroll_report` (`read:payroll`), `mango_gtm` (`read:mango_gtm`), the attendance areas
        (`timesheets`, `shifts`, `leave_requests`, `absence_reports`,
        `shift_marketplace` — `read:attendance`) and `profile_update_requests`
        (`read:employee_data`). Such a section is removed before ranking, so it
        never reaches `best_match`, `top` or `filter_options.in` either.

        Each search writes one Search Analytics row with source `api`.
      operationId: globalSearch
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: The query (2–200 characters). Omit for the page before you type.
        schema:
          type: string
          maxLength: 200
        example: leave policy
      - name: in
        in: query
        required: false
        description: Keep one area — a key from `filter_options.in` (e.g. `policy_hub`,
          `people`, `wikis`).
        schema:
          type: string
        example: policy_hub
      - name: since
        in: query
        required: false
        description: The "When" filter — keep rows dated after the cutoff.
        schema:
          type: string
          enum:
          - 30d
          - 90d
          - year
      - name: when
        in: query
        required: false
        description: Alias for `since`; `since` wins when both are sent.
        schema:
          type: string
          enum:
          - 30d
          - 90d
          - year
      - name: mine
        in: query
        required: false
        description: The "Only mine" filter — `1` or `true`.
        schema:
          type: string
          enum:
          - '1'
          - 'true'
      - name: limit
        in: query
        required: false
        description: Rows per section in `sections` and `external_sections` (each
          section's `count` is still its full size).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: 'The page before you type (no `q`: `query`, `filters`, `home`)
            or the results (`q` given: every other property).'
          content:
            application/json:
              schema:
                type: object
                required:
                - query
                - filters
                properties:
                  query:
                    type: string
                    description: The query as searched — `q`, trimmed; `""` for the
                      home.
                    example: leave policy
                  filters:
                    "$ref": "#/components/schemas/SearchFilters"
                  home:
                    type: object
                    description: Only without `q`.
                    properties:
                      for_you:
                        type: array
                        description: Things with a due date waiting on the caller,
                          by area.
                        items:
                          "$ref": "#/components/schemas/SearchSection"
                      saved_searches:
                        type: array
                        description: The caller's saved searches, most recently used
                          first (at most 8).
                        items:
                          "$ref": "#/components/schemas/SearchSavedSearch"
                      recent_searches:
                        type: array
                        description: The caller's own recent queries, newest first
                          (at most 6, last 30 days).
                        items:
                          type: string
                        example:
                        - expense form
                        - leave policy
                  active_filters:
                    type: array
                    description: The filter chips — each with the `filters` params
                      that remove it.
                    items:
                      type: object
                      properties:
                        label:
                          type: string
                          example: Last 30 days
                        remove:
                          "$ref": "#/components/schemas/SearchFilters"
                  filter_options:
                    type: object
                    description: The Filter control — every choice and whether it
                      is selected.
                    properties:
                      in:
                        type: array
                        description: '"Everywhere" (`key: null`) first, then the areas
                          this search found (before filtering, so a filter never hides
                          the way back), then the common areas this caller can open.'
                        items:
                          "$ref": "#/components/schemas/SearchFilterOption"
                      since:
                        type: array
                        description: '"Any time" (`key: null`), then `30d`, `90d`,
                          `year`.'
                        items:
                          "$ref": "#/components/schemas/SearchFilterOption"
                      mine:
                        type: object
                        properties:
                          title:
                            type: string
                            example: Only mine
                          selected:
                            type: boolean
                  live_answer:
                    type: object
                    nullable: true
                    description: A structured fact the query asked for (e.g. "who
                      is my manager"), when there is one.
                    additionalProperties: true
                  best_match:
                    allOf:
                    - "$ref": "#/components/schemas/SearchRow"
                    nullable: true
                    description: The single strongest row; null when nothing scores,
                      or when it only repeats the live answer.
                  top:
                    type: array
                    description: The ranked top rows across every area (each names
                      its `section`).
                    items:
                      "$ref": "#/components/schemas/SearchRow"
                  shortcuts:
                    type: array
                    description: Pages to go to ("Go to"). Empty under any filter.
                    items:
                      type: object
                      additionalProperties: true
                  experts:
                    type: array
                    description: People who can help — owners of the matched content,
                      holders of a matching skill, title or place.
                    items:
                      type: object
                      properties:
                        user_id:
                          type: integer
                        name:
                          type: string
                        url:
                          type: string
                        reasons:
                          type: array
                          items:
                            type: string
                          example:
                          - 'Skill: Forklift Operation'
                        person:
                          "$ref": "#/components/schemas/SearchPersonCard"
                  sections:
                    type: array
                    description: The results by area, in the engine's order.
                    items:
                      "$ref": "#/components/schemas/SearchSection"
                  external_sections:
                    type: array
                    description: Connected external sources (Enterprise). `state`
                      is `results`, `connect` (the caller has not signed in to the
                      source — `connect_url` starts it) or `unavailable` (with a `reason`).
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          example: external-7
                        title:
                          type: string
                        source_id:
                          type: integer
                        host:
                          type: string
                        state:
                          type: string
                          enum:
                          - results
                          - connect
                          - unavailable
                        reconnect:
                          type: boolean
                        reason:
                          type: string
                        connect_url:
                          type: string
                          format: uri
                          description: The person's My connections page, as an absolute
                            URL to open in a browser, where they connect (or, with
                            `reconnect`, reconnect) the source. Present only when
                            `state` is `connect`.
                        more_url:
                          type: string
                          description: 'Where to see the rest when the source has
                            more matches than this page shows: the source''s own site,
                            usually its search for this query (Slack opens the workspace).
                            Opens outside the product.'
                        next_url:
                          type: string
                          description: The next page of this source's results, as
                            a web page path on the person's site. Open it in a browser
                            signed in as the same person; it is bound to them, this
                            query and their current connection, and is valid for 15
                            minutes (an expired one starts the results again from
                            the first page).
                        date_filtered:
                          type: boolean
                          description: True when a `since` filter removed some of
                            this source's rows.
                        count:
                          type: integer
                        items:
                          type: array
                          items:
                            "$ref": "#/components/schemas/SearchRow"
                  did_you_mean:
                    type: string
                    nullable: true
                    description: A suggested query when the results are empty or thin
                      (never under a filter).
                  saved_search:
                    allOf:
                    - "$ref": "#/components/schemas/SearchSavedSearch"
                    nullable: true
                    description: This exact search (query and filters) when the caller
                      has saved it — its `id` is what DELETE /search/saved_searches/{id}
                      takes; save it with POST /search/saved_searches.
                  ask_ai_enabled:
                    type: boolean
                    description: Whether Ask AI is available to the caller (the page's
                      "Open Ask AI").
                  ai_overview_available:
                    type: boolean
                    description: Whether the page would show an AI overview for this
                      search.
                  ai_overview_url:
                    type: string
                    nullable: true
                    description: The GET /search/overview URL to call for the AI overview
                      card, after drawing the results (the page lazy-loads it). Present
                      only when the page would show the card — Ask AI on for the caller,
                      no live answer, no filter — and carries `ext`, the external
                      sources that answered.
                    example: "/api/v1/search/overview?q=leave+policy"
                  meta:
                    type: object
                    properties:
                      elapsed_ms:
                        type: integer
                        nullable: true
                      total:
                        type: integer
                        description: Rows returned, including ranked rows that belong
                          to no section (meaning matches, promoted).
                      match_total:
                        type: integer
                        description: The page's "N results" — each section's full
                          match count where its area capped the rows, plus external
                          rows.
                      external_total:
                        type: integer
                      nothing_found:
                        type: boolean
                        description: True when the page would show "Nothing matched".
                      incomplete:
                        type: boolean
                        description: True when some areas did not answer (out of time,
                          or failed) and are missing from this response; the page
                          says "Some areas could not be searched just now". Search
                          again to retry.
              examples:
                results:
                  summary: GET /search?q=leave%20policy&since=90d
                  value:
                    query: leave policy
                    filters:
                      since: 90d
                    active_filters:
                    - label: Last 90 days
                      remove: {}
                    filter_options:
                      in:
                      - key:
                        title: Everywhere
                        selected: true
                      - key: policy_hub
                        title: Policies
                        selected: false
                      since:
                      - key:
                        title: Any time
                        selected: false
                      - key: 30d
                        title: Last 30 days
                        selected: false
                      - key: 90d
                        title: Last 90 days
                        selected: true
                      - key: year
                        title: This year
                        selected: false
                      mine:
                        title: Only mine
                        selected: false
                    live_answer:
                    best_match:
                      id: 42
                      title: Leave Policy
                      url: "/apps/policy_hub/policies/42"
                      section: policy_hub
                      section_title: Policies
                      updated_at: '2026-09-30T10:00:00Z'
                    top: []
                    shortcuts: []
                    experts: []
                    sections:
                    - key: policy_hub
                      title: Policies
                      count: 1
                      see_all_url: "/apps/policy_hub/policies?q=leave+policy"
                      see_all_title: Policy Hub
                      items:
                      - id: 42
                        title: Leave Policy
                        url: "/apps/policy_hub/policies/42"
                        icon: fa-solid fa-file-shield
                        updated_at: '2026-09-30T10:00:00Z'
                    external_sections: []
                    did_you_mean:
                    saved_search:
                    ask_ai_enabled: true
                    ai_overview_available: false
                    meta:
                      elapsed_ms: 412
                      total: 1
                      match_total: 1
                      external_total: 0
                      nothing_found: false
                      incomplete: false
                home:
                  summary: GET /search (no q)
                  value:
                    query: ''
                    filters: {}
                    home:
                      for_you: []
                      saved_searches:
                      - id: 3
                        name: expense form
                        query: expense form
                        filters:
                          since: 30d
                        alerting: true
                        alert_cadence: weekly
                        cadence_label: Weekly
                      recent_searches:
                      - leave policy
        '400':
          description: "`q` is one character (`invalid_query`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token holds neither `read:search` nor `read:my_data` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/search/overview":
    get:
      tags:
      - Search
      summary: The AI overview card for a search
      description: |
        The AI overview the results page lazy-loads after it has painted, as
        data. Call the `ai_overview_url` that `GET /search` returns: it is
        present only when the page would show the card.

        `status` is `answered` (the `text` with its `[n]` markers, `from`, one
        source chip per document with its trust line, and every cited marker),
        `not_documented` (show "Nothing in company knowledge answers this
        directly — the results below may.") or `skipped` (draw nothing — Ask AI
        or company knowledge is off for the caller).

        Runs a model on the organization's AI credits, like the web card. An
        answer that read an external source is never cached and is sent with
        `Cache-Control: no-store`. Takes `read:search` or `read:my_data`.
      operationId: searchOverview
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
          minLength: 2
          maxLength: 200
      - name: ext
        in: query
        required: false
        description: External source ids that answered, comma-separated — as given
          in `ai_overview_url`.
        schema:
          type: string
          example: '3,7'
      responses:
        '200':
          description: The card.
          content:
            application/json:
              schema:
                type: object
                properties:
                  query:
                    type: string
                  status:
                    type: string
                    enum:
                    - answered
                    - not_documented
                    - skipped
                  text:
                    type: string
                    description: The answer, with [n] markers naming `citations`.
                      Answered only.
                    example: Full-time employees accrue 20 days of paid time off a
                      year [1][2].
                  from:
                    type: string
                    description: Where the answer came from, for the card's label.
                      Answered only.
                    example: company knowledge and SharePoint — Sample
                  sources:
                    type: array
                    description: One chip per cited document.
                    items:
                      type: object
                      properties:
                        marker:
                          type: string
                          example: "[1]"
                        title:
                          type: string
                        url:
                          type: string
                          description: Absent when the source's link is not safe to
                            open; keep the chip as text.
                        external:
                          type: boolean
                          description: Opens outside MangoApps.
                        trust:
                          type: array
                          description: The chip's trust line, in order — who it is
                            for, one trust word, the owner.
                          items:
                            type: object
                            properties:
                              text:
                                type: string
                                example: Review overdue
                              warning:
                                type: boolean
                  citations:
                    type: array
                    description: Every cited marker, for linking the [n] markers in
                      `text`.
                    items:
                      type: object
                      properties:
                        marker:
                          type: string
                        title:
                          type: string
                        url:
                          type: string
                        external:
                          type: boolean
                  meta:
                    type: object
                    properties:
                      took_ms:
                        type: integer
                        nullable: true
        '400':
          description: "`q` is shorter than 2 characters (`invalid_query`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token holds neither `read:search` nor `read:my_data` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/search/recent":
    delete:
      tags:
      - Search
      summary: Clear the caller's recent searches
      description: |
        The Search page's **Clear** beside Recent searches, as data: from now on
        `home.recent_searches` lists only what the caller searches afterwards, on
        every device, and the web header box drops what was typed before it
        after the next full page load in each browser. The search log itself is kept (Analytics reads it in
        aggregate). Only the caller's own list, in this organization. Needs
        `write:personal`.
      operationId: searchClearRecent
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cleared:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Recent searches cleared.
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token does not hold `write:personal` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: The caller is not a member of this organization.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/search/lookahead":
    get:
      tags:
      - Search
      summary: Lookahead — the results listed while the caller is still typing
      description: |
        What the search box shows before the caller presses Enter: the same
        fast engine, live answers, promotions, ranking and permissions as the
        web omnibox (`/global_search?fast=1`), so a native app's lookahead
        never ranks or leaks differently from the browser's. Call it on a
        debounced keystroke; for the full results (every area, filters,
        experts, connected sources) call `GET /search?q=` when the caller
        presses Enter or taps "See all results".

        It runs only the fast areas (people, apps, settings, policies, wikis,
        documents, tasks, …) plus the areas of the app named in `app`, and it
        never records a search in Search Analytics — a keystroke is not a
        search; `GET /search` is.

        ### The body, in the order a client draws it

        * `live_answer` — a structured fact the query asked for ("next holiday",
          "my manager"), when there is one.
        * `best_match` — the single strongest row; null when nothing scores, or
          when it only repeats the live answer.
        * `rows` — the flat list the phone lookahead draws (at most 8): the live
          answer and the ranked rows first, then the rest in area order, with
          rows that would look identical folded into one.
        * `shortcuts` — pages to go to ("Go to").
        * `sections` — the results by area; each carries its full `count`, at
          most `limit` `items`, and `see_all_url` ("View all policies") where
          the area has its own search.

        Rows carry their `actions` (Acknowledge a pending policy, Pick up an
        open shift, Fill a form, Complete a task, Register for an event), each
        an existing route with its app's own gate.

        ### Scope ceiling

        Takes `read:search` (what the MangoApps app sign-ins grant) or
        `read:my_data`, as `GET /search` does. The same ceiling applies: an
        area that needs its own scope
        (`read:payroll`, `read:hr_file_manager`, `read:attendance`, …) is
        dropped before ranking, so it never reaches `best_match` or `rows`
        either, unless the token holds that scope.
      operationId: searchLookahead
      security:
      - bearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        description: What the caller has typed so far — at least 2 characters, at
          most 200 (longer is cut).
        schema:
          type: string
          minLength: 2
          maxLength: 200
        example: policy
      - name: app
        in: query
        required: false
        description: The app the search box is in (its slug, e.g. `service-desk`).
          That app's own areas join the fast ones, so searching from inside an app
          finds its records without "See all". Unknown slugs are ignored.
        schema:
          type: string
        example: policy-hub
      - name: limit
        in: query
        required: false
        description: Rows per section in `sections[].items` (1–50). `count` always
          says how many matched.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 3
      responses:
        '200':
          description: The as-you-type list.
          content:
            application/json:
              schema:
                type: object
                required:
                - query
                - rows
                - sections
                - shortcuts
                - meta
                properties:
                  query:
                    type: string
                    description: The query as searched — `q`, trimmed.
                    example: policy
                  live_answer:
                    type: object
                    nullable: true
                    description: A structured fact the query asked for, when there
                      is one.
                    additionalProperties: true
                  best_match:
                    allOf:
                    - type: object
                      description: One result. Fields a row has no value for are omitted.
                      properties:
                        id:
                          description: The record's id within its area (people rows
                            carry the user id).
                          oneOf:
                          - type: integer
                          - type: string
                        title:
                          type: string
                        url:
                          type: string
                        subtitle:
                          type: string
                        icon:
                          type: string
                        section:
                          type: string
                          description: The area a ranked row (`best_match`, `top`)
                            came from.
                        section_title:
                          type: string
                        updated_at:
                          type: string
                          format: date-time
                        owner:
                          type: string
                        owner_id:
                          type: integer
                        version:
                          type: string
                        event_dated:
                          type: boolean
                          description: True when `updated_at` is when the thing happens
                            (a shift's start), not when it was last edited.
                        outdated:
                          type: boolean
                        takes_effect_on:
                          type: string
                          format: date
                        semantic:
                          type: boolean
                          description: Found by meaning rather than by its words.
                        promoted:
                          type: boolean
                          description: An admin-promoted result for this query.
                        external_host:
                          type: string
                        snippet:
                          type: string
                          description: An external source's excerpt.
                        passage:
                          type: string
                          maxLength: 320
                          description: Where the row matched, as the results page
                            draws it under the row — the sentence a Messages result's
                            word is in (its subject, for a thread matched on it),
                            a page's matched paragraph, or the record's summary (a
                            summary's markdown is stripped). Text to draw as-is, never
                            HTML; at most 320 characters.
                        passage_term:
                          type: string
                          description: The words to mark in `passage` (case-insensitive),
                            as the web marks them. Sent only with a passage, and only
                            when it is at least two characters.
                        score:
                          type: number
                        reasons:
                          type: array
                          items:
                            type: string
                        applies_chip:
                          type: string
                          description: A policy row's applicability chip, e.g. "Applies
                            to you · Sales" or "For California employees".
                        status:
                          type: string
                          description: What the caller already did with the row, in
                            place of its action — "Pick-up requested · pending approval",
                            "You're registered", "On the waitlist" or "You attended".
                            Absent otherwise.
                        actions:
                          type: array
                          description: 'What the caller can do from the row (e.g.
                            acknowledge, open). Each is `{label, url, method, primary,
                            confirm}`. A `get` action is a page to open. A `post`
                            action is the web app''s own form endpoint: it needs a
                            signed-in browser session and its CSRF token, not a bearer
                            token — open the row''s own `url` in a web view, or call
                            that app''s own API for the action.'
                          items:
                            type: object
                            additionalProperties: true
                        person:
                          allOf:
                          - "$ref": "#/components/schemas/SearchPersonCard"
                          description: On every People row — in the People section
                            and as the best match or a top row.
                    nullable: true
                    description: The single strongest row; null when nothing scores,
                      or when it only repeats the live answer.
                  rows:
                    type: array
                    description: 'The flat lookahead list (at most 8): a linkable
                      live answer (`section: live`), the best match and the ranked
                      rows, then the rest in area order — one row per destination
                      and per visible line.'
                    items:
                      type: object
                      description: One result. Fields a row has no value for are omitted.
                      properties:
                        id:
                          description: The record's id within its area (people rows
                            carry the user id).
                          oneOf:
                          - type: integer
                          - type: string
                        title:
                          type: string
                        url:
                          type: string
                        subtitle:
                          type: string
                        icon:
                          type: string
                        section:
                          type: string
                          description: The area a ranked row (`best_match`, `top`)
                            came from.
                        section_title:
                          type: string
                        updated_at:
                          type: string
                          format: date-time
                        owner:
                          type: string
                        owner_id:
                          type: integer
                        version:
                          type: string
                        event_dated:
                          type: boolean
                          description: True when `updated_at` is when the thing happens
                            (a shift's start), not when it was last edited.
                        outdated:
                          type: boolean
                        takes_effect_on:
                          type: string
                          format: date
                        semantic:
                          type: boolean
                          description: Found by meaning rather than by its words.
                        promoted:
                          type: boolean
                          description: An admin-promoted result for this query.
                        external_host:
                          type: string
                        snippet:
                          type: string
                          description: An external source's excerpt.
                        passage:
                          type: string
                          maxLength: 320
                          description: Where the row matched, as the results page
                            draws it under the row — the sentence a Messages result's
                            word is in (its subject, for a thread matched on it),
                            a page's matched paragraph, or the record's summary (a
                            summary's markdown is stripped). Text to draw as-is, never
                            HTML; at most 320 characters.
                        passage_term:
                          type: string
                          description: The words to mark in `passage` (case-insensitive),
                            as the web marks them. Sent only with a passage, and only
                            when it is at least two characters.
                        score:
                          type: number
                        reasons:
                          type: array
                          items:
                            type: string
                        applies_chip:
                          type: string
                          description: A policy row's applicability chip, e.g. "Applies
                            to you · Sales" or "For California employees".
                        status:
                          type: string
                          description: What the caller already did with the row, in
                            place of its action — "Pick-up requested · pending approval",
                            "You're registered", "On the waitlist" or "You attended".
                            Absent otherwise.
                        actions:
                          type: array
                          description: 'What the caller can do from the row (e.g.
                            acknowledge, open). Each is `{label, url, method, primary,
                            confirm}`. A `get` action is a page to open. A `post`
                            action is the web app''s own form endpoint: it needs a
                            signed-in browser session and its CSRF token, not a bearer
                            token — open the row''s own `url` in a web view, or call
                            that app''s own API for the action.'
                          items:
                            type: object
                            additionalProperties: true
                        person:
                          allOf:
                          - "$ref": "#/components/schemas/SearchPersonCard"
                          description: On every People row — in the People section
                            and as the best match or a top row.
                  shortcuts:
                    type: array
                    description: Pages to go to ("Go to").
                    items:
                      type: object
                      properties:
                        title:
                          type: string
                          example: Policy Hub
                        path:
                          type: string
                          example: "/apps/policy-hub"
                  sections:
                    type: array
                    description: The results by area, in the engine's order.
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          example: policy_hub
                        title:
                          type: string
                          example: Policies
                        count:
                          type: integer
                          description: Rows in this section (`items` holds at most
                            `limit` of them).
                        total:
                          type: integer
                          description: The area's full match count, only when it capped
                            its rows ("8 of 46"). On `home.for_you`, the full number
                            owed (policies to acknowledge, tasks due) when the home
                            shows only the first few.
                        see_all_url:
                          type: string
                          description: The app's own search for this query, where
                            it has one. On `home.for_you`, the app's own list of everything
                            owed (Policy Hub's pending acknowledgments, My Tasks,
                            open shifts).
                        see_all_title:
                          type: string
                        also_in:
                          type: boolean
                          description: True when the page folds this section into
                            its "Also in" block — a late section (after the first
                            six) holding one row, when at least five such sections
                            exist. People never fold.
                        items:
                          type: array
                          items:
                            "$ref": "#/components/schemas/SearchRow"
                  ask_ai_enabled:
                    type: boolean
                    description: Whether Ask AI is available to the caller.
                  meta:
                    type: object
                    properties:
                      elapsed_ms:
                        type: integer
                        nullable: true
                        description: The engine's time for this lookahead.
                      total:
                        type: integer
                        description: Rows across `sections` (before `limit`).
                      ranked:
                        type: boolean
                        description: False when ranking was skipped after an error
                          — `rows` is then the plain area-order list and `best_match`
                          and `live_answer` are null.
              examples:
                policy:
                  summary: GET /search/lookahead?q=policy
                  value:
                    query: policy
                    live_answer:
                    best_match:
                      id: 12
                      title: Data Privacy Policy
                      url: "/apps/my_bookmarks/12"
                      section: my_bookmarks
                      section_title: My bookmarks
                      reasons:
                      - Yours
                    rows:
                    - id: 12
                      title: Data Privacy Policy
                      url: "/apps/my_bookmarks/12"
                      section: my_bookmarks
                      section_title: My bookmarks
                    - id: 42
                      title: Paid Time Off Policy
                      url: "/apps/policy-hub/policies/42"
                      subtitle: Leave · v3
                      section: policy_hub
                      section_title: Policies
                      actions:
                      - label: Acknowledge
                        url: "/apps/policy-hub/acknowledge/42/view"
                        method: get
                        primary: true
                    shortcuts:
                    - title: Policy Hub
                      path: "/apps/policy-hub"
                    sections:
                    - key: policy_hub
                      title: Policies
                      count: 5
                      see_all_url: "/apps/policy-hub/policies?search=policy"
                      see_all_title: Policy Hub
                      items:
                      - id: 42
                        title: Paid Time Off Policy
                        url: "/apps/policy-hub/policies/42"
                        subtitle: Leave · v3
                        actions:
                        - label: Acknowledge
                          url: "/apps/policy-hub/acknowledge/42/view"
                          method: get
                          primary: true
                      - id: 43
                        title: Anti-Harassment & Non-Discrimination Policy
                        url: "/apps/policy-hub/policies/43"
                      - id: 44
                        title: Attendance & Punctuality Policy
                        url: "/apps/policy-hub/policies/44"
                    ask_ai_enabled: true
                    meta:
                      elapsed_ms: 154
                      total: 9
                      ranked: true
        '400':
          description: "`q` is missing or one character (`invalid_query`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token holds neither `read:search` nor `read:my_data` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/search/saved_searches":
    get:
      tags:
      - Search
      summary: List the caller's saved searches
      description: |
        The caller's own saved searches in this organisation, newest saved
        first. The order never changes when a search is edited later, as on the
        Saved searches page. A person keeps at most `meta.max` (30), so the list
        is one page.

        Takes what `GET /search` takes — `read:search` (the app sign-ins) or
        `read:my_data` (an AI connector) — since its page before you type already
        lists them.

        An alerting search whose alerts cannot find anything says why:
        `alerts_muted` (the person's or the organisation's notification
        settings) or `area_closed` — `excluded` when the one area it is
        narrowed to (`filters.in`) is switched off in the organisation's
        Search settings, `no_access` when the person can no longer open that
        area's app.
      operationId: listSavedSearches
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      responses:
        '200':
          description: The list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved_searches:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        query:
                          type: string
                        filters:
                          "$ref": "#/components/schemas/SearchFilters"
                        alerting:
                          type: boolean
                        alert_cadence:
                          type: string
                          enum:
                          - 'off'
                          - daily
                          - weekly
                        cadence_label:
                          type: string
                          example: Weekly
                        last_alerted_at:
                          type: string
                          format: date-time
                          description: When the last alert went out. Absent until
                            one has (a search saved with alerts off never has one).
                        last_match_count:
                          type: integer
                          description: The new results that last alert reported ("4
                            new"). Only alongside `last_alerted_at`.
                          example: 4
                        last_checked_at:
                          type: string
                          format: date-time
                          description: When the alert pass last reached this search,
                            whether or not it found any — "Checked 2 hours ago · nothing
                            new yet". A daily alert checked days ago is not running.
                            A pass also stamps it when it skips a muted alert, so
                            read it beside `alerts_muted`.
                        alerts_muted:
                          type: string
                          enum:
                          - mine
                          - organisation
                          description: Present on an alerting search whose alerts
                            will not be delivered — `mine` when the person muted Search
                            notices in their notification settings, `organisation`
                            when the organisation's settings turn them off.
                        area_closed:
                          type: string
                          enum:
                          - excluded
                          - no_access
                          description: Present on an alerting search narrowed (`filters.in`)
                            to an area the organisation switched off in Search settings
                            (`excluded`) or whose app the person can no longer open
                            (`no_access`); such a search finds nothing on any pass,
                            so it is not checked.
                        created_at:
                          type: string
                          format: date-time
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                        example: 2
                      max:
                        type: integer
                        description: How many searches one person may keep.
                        example: 30
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token holds neither `read:search` nor `read:my_data` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    post:
      tags:
      - Search
      summary: Save a search
      description: |
        "Save this search" from the results page: the query and the filters the
        results were shown with (`in`, `since` or its alias `when`, `mine`),
        stored as the page stores them. So `GET /search` with the same query and
        filters answers this row as `saved_search`, and its `id` is what
        `DELETE` takes.

        Alerts are off unless `alert_cadence` asks for `daily` or `weekly`; any
        other value is off. What already matches when the alert is turned on is
        never reported as new: that baseline is taken in the background right
        after a save with an alert, or when `PATCH` turns the alert on later.
        Alerts watch OneMango content, not connected sources.

        The same query with different filters is a different saved search.
        Saving one that is already saved answers `409 already_saved` with the
        existing row in `error.details.saved_search`.

        Needs `write:personal`.
      operationId: saveSearch
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - q
              properties:
                q:
                  type: string
                  minLength: 2
                  maxLength: 200
                  example: leave policy
                in:
                  type: string
                  description: The In filter — a section key from `GET /search`'s
                    `filter_options.in`.
                  example: policy_hub
                since:
                  type: string
                  enum:
                  - 30d
                  - 90d
                  - year
                  description: The When filter.
                when:
                  type: string
                  enum:
                  - 30d
                  - 90d
                  - year
                  description: Alias for `since`; stored as `since`.
                mine:
                  description: The Only mine filter — `1` or `true`.
                  oneOf:
                  - type: string
                    enum:
                    - '1'
                    - 'true'
                  - type: boolean
                alert_cadence:
                  type: string
                  enum:
                  - 'off'
                  - daily
                  - weekly
                  default: 'off'
      responses:
        '201':
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved_search:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      query:
                        type: string
                      filters:
                        "$ref": "#/components/schemas/SearchFilters"
                      alerting:
                        type: boolean
                      alert_cadence:
                        type: string
                        enum:
                        - 'off'
                        - daily
                        - weekly
                      cadence_label:
                        type: string
                        example: Weekly
                      last_alerted_at:
                        type: string
                        format: date-time
                        description: When the last alert went out. Absent until one
                          has (a search saved with alerts off never has one).
                      last_match_count:
                        type: integer
                        description: The new results that last alert reported ("4
                          new"). Only alongside `last_alerted_at`.
                        example: 4
                      last_checked_at:
                        type: string
                        format: date-time
                        description: When the alert pass last reached this search,
                          whether or not it found any — "Checked 2 hours ago · nothing
                          new yet". A daily alert checked days ago is not running.
                          A pass also stamps it when it skips a muted alert, so read
                          it beside `alerts_muted`.
                      alerts_muted:
                        type: string
                        enum:
                        - mine
                        - organisation
                        description: Present on an alerting search whose alerts will
                          not be delivered — `mine` when the person muted Search notices
                          in their notification settings, `organisation` when the
                          organisation's settings turn them off.
                      area_closed:
                        type: string
                        enum:
                        - excluded
                        - no_access
                        description: Present on an alerting search narrowed (`filters.in`)
                          to an area the organisation switched off in Search settings
                          (`excluded`) or whose app the person can no longer open
                          (`no_access`); such a search finds nothing on any pass,
                          so it is not checked.
                      created_at:
                        type: string
                        format: date-time
                  message:
                    type: string
                    description: The sentence the web page shows after a save.
                    example: Saved “leave policy”. Turn on alerts under Saved searches
                      to hear about new results. Alerts watch OneMango content, not
                      connected sources.
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token lacks `write:personal` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: This search, with these filters, is already saved (`already_saved`,
            "You've already saved this search."). `error.details.saved_search` is
            the saved row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Not saved (`invalid_saved_search`), with the page's own sentence
            — e.g. "Type at least 2 characters before saving a search.", or the limit
            of 30 saved searches reached.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/search/saved_searches/{id}":
    patch:
      tags:
      - Search
      summary: Rename a saved search or set its alert
      description: |
        What the Saved searches page's Rename and alert controls do. Send
        `name`, `alert_cadence`, or both; only what is sent changes. `PUT`
        does the same.

        Turning an alert on (from `off` to `daily` or `weekly`) means "new
        from now on": what the search matches at that moment is taken as the
        baseline in the background and is never reported as new. Switching
        between `daily` and `weekly` keeps the baseline. Alerts watch
        OneMango content, not connected sources.

        Any other id, including another person's, answers `404` and changes
        nothing. Needs `write:personal`.
      operationId: updateSavedSearch
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The saved search's id (from the list, a save, or `GET /search`'s
          `saved_search`).
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: The name shown instead of the query; blank shows the
                    query.
                  example: Leave questions
                alert_cadence:
                  type: string
                  enum:
                  - 'off'
                  - daily
                  - weekly
      responses:
        '200':
          description: Changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved_search:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      query:
                        type: string
                      filters:
                        "$ref": "#/components/schemas/SearchFilters"
                      alerting:
                        type: boolean
                      alert_cadence:
                        type: string
                        enum:
                        - 'off'
                        - daily
                        - weekly
                      cadence_label:
                        type: string
                        example: Weekly
                      last_alerted_at:
                        type: string
                        format: date-time
                        description: When the last alert went out. Absent until one
                          has (a search saved with alerts off never has one).
                      last_match_count:
                        type: integer
                        description: The new results that last alert reported ("4
                          new"). Only alongside `last_alerted_at`.
                        example: 4
                      last_checked_at:
                        type: string
                        format: date-time
                        description: When the alert pass last reached this search,
                          whether or not it found any — "Checked 2 hours ago · nothing
                          new yet". A daily alert checked days ago is not running.
                          A pass also stamps it when it skips a muted alert, so read
                          it beside `alerts_muted`.
                      alerts_muted:
                        type: string
                        enum:
                        - mine
                        - organisation
                        description: Present on an alerting search whose alerts will
                          not be delivered — `mine` when the person muted Search notices
                          in their notification settings, `organisation` when the
                          organisation's settings turn them off.
                      area_closed:
                        type: string
                        enum:
                        - excluded
                        - no_access
                        description: Present on an alerting search narrowed (`filters.in`)
                          to an area the organisation switched off in Search settings
                          (`excluded`) or whose app the person can no longer open
                          (`no_access`); such a search finds nothing on any pass,
                          so it is not checked.
                      created_at:
                        type: string
                        format: date-time
                  message:
                    type: string
                    description: The sentence the web page shows after the change.
                    example: Updated “leave policy” — daily alerts, for results new
                      from now on. Alerts watch OneMango content, not connected sources.
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token lacks `write:personal` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: Not one of the caller's saved searches (`not_found`, "That
            saved search is gone.").
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: Not changed (`invalid_saved_search`), with the page's own sentence
            — e.g. "Choose No alerts, Daily or Weekly." for an unknown cadence.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Search
      summary: Remove a saved search
      description: |
        Removes one of the caller's own saved searches. Any other id, including
        another person's, answers `404` and removes nothing. Needs `write:personal`.
      operationId: removeSavedSearch
      security:
      - BearerAuth: []
      - ServiceAccountAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The saved search's id (from the list, a save, or `GET /search`'s
          `saved_search`).
        schema:
          type: integer
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  removed:
                    type: boolean
                    example: true
                  id:
                    type: integer
                    example: 3
                  message:
                    type: string
                    example: Removed “leave policy”.
        '401':
          description: Missing or invalid Bearer token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '403':
          description: The token lacks `write:personal` (`insufficient_permissions`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: Not one of the caller's saved searches (`not_found`, "That
            saved search is gone.").
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: The row could not be removed (`not_removed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/libraries/{id}/disable":
    parameters:
    - name: id
      in: path
      required: true
      description: The LIBRARY id (not a category, and not an item).
      schema:
        type: integer
    post:
      tags:
      - Libraries
      summary: Disable a library
      description: |
        Takes the library **out of circulation** — the native mirror of the web
        **Disable** control (the All Libraries row menu and the library header,
        both of which reach
        `Apps::Libraries::SpacesController#toggle_enabled`). Both surfaces perform
        the transition through the shared `Libraries::EnablementService`, so
        neither can drift on what disabling means, who may do it, or what the
        confirmation says.

        A disabled library **drops off every browse, search and mobile surface**.
        It stays visible — badged `enabled: false` — only to callers who may
        administer Libraries, because the index is the surface that offers the way
        back. Its categories, items and audience rules are **untouched**: this is
        a reversible hide, not a delete.

        ### Idempotent, deliberately NOT a toggle

        This is the one place the endpoint diverges from the web control it
        mirrors. A button can safely mean "flip it"; a network client retries, and
        a second toggle would silently **re-enable** a library the caller had just
        taken down.

        So disabling an already-disabled library is a no-op that still answers
        `200`: it writes nothing, leaves `updated_at` untouched, and reports
        `changed: false`. Ten retries are indistinguishable from one call. Read
        `changed` to know whether this call was the one that moved the flag.

        The way back is the sibling verb, `POST /libraries/{id}/enable`. The web
        renders both as ONE control whose label flips; this API splits it into
        two verbs precisely so a retry cannot undo itself.

        ### Authorization — three gates

        1. **Token scope `write:libraries`.** Unlike the read surface in this
           file, the write is scope-gated. The scope IS grantable: it reaches
           `ApiToken::AVAILABLE_SCOPES` through `Mcp::ScopeRegistry`, which mints
           `read:` / `write:` / `destructive:` for every `Agents::ToolRegistry`
           domain, so an admin-issued token can hold it and the mobile login paths
           already mint it. `has_scope?` fails **closed** on a scopeless token, and
           the blanket `admin` scope satisfies it. A session-authenticated caller
           (native WebView, internal call) carries no token and passes, exactly as
           `require_scope` defines it.
        2. **Libraries app access** — enablement (explicit row OR pricing tier OR
           active subscription) AND the app's per-group visibility. Otherwise
           `403 access_denied`.
        3. **Libraries administrator** — a Libraries admin or a place-limited
           one, precisely what the index's `can_disable` flag reports. Business
           admins, super admins, **per-app Libraries admins** and place-limited
           Libraries admins qualify; a manager with no Libraries grant does not.
           Otherwise `403 forbidden`.
        4. **This library** — `Libraries::Access#can_toggle_library?`, the
           web's `toggle_enabled` gate: a place-limited admin only for a library
           in their reach. Otherwise `403 forbidden`.

        Gate 3 is **app-wide, not per-library**, and is answered BEFORE the
        library is resolved. So a caller with no Libraries admin grant gets
        `403` for a real id and for a nonexistent one alike, and cannot
        enumerate library ids by reading `403`-vs-`404`. A library belonging to
        another tenant is `404`, never `403` — a `403` would confirm the id is
        real somewhere.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:libraries` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The library is disabled. Returned whether this call changed
            it or it was already off — read `changed` to tell those apart.
          content:
            application/json:
              schema:
                type: object
                required:
                - library
                - changed
                - message
                properties:
                  library:
                    allOf:
                    - "$ref": "#/components/schemas/LibraryCard"
                    description: The library AFTER the call, in the same card shape
                      `GET /libraries/list` emits — counts included — so a client
                      redraws the row from this one response with no follow-up. `enabled`
                      is always `false` here.
                  changed:
                    type: boolean
                    description: "`true` when this call moved the flag; `false` when
                      the library was already disabled and nothing was written."
                    example: true
                  message:
                    type: string
                    description: Confirmation copy, byte-identical to the web flash
                      — it describes what is TRUE now, so it reads the same on a retry.
                    enum:
                    - Library disabled successfully.
                    example: Library disabled successfully.
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token (`authentication_required`).
        '403':
          description: The token lacks `write:libraries` (`insufficient_permissions`);
            the Libraries app is not enabled for the tenant or the caller is outside
            its audience (`access_denied`); or the caller is not a Libraries administrator
            (`forbidden`). Nothing is written in any of the three.
        '404':
          description: No library with that id exists in the caller's tenant (`not_found`).
            Another tenant's library answers this too — never `403`.
        '422':
          description: The library could not be saved (`invalid`). The `message` names
            the validation that refused it.
  "/libraries/{id}/enable":
    parameters:
    - name: id
      in: path
      required: true
      description: The LIBRARY id (not a category, and not an item).
      schema:
        type: integer
    post:
      tags:
      - Libraries
      summary: Enable a library
      description: |
        Puts a disabled library **back into circulation** — the way back from
        `POST /libraries/{id}/disable`, and the native mirror of the web
        **Enable** control.

        On the web, Enable and Disable are the SAME control: the All Libraries
        row menu and the library header render one entry whose label flips with
        the current state, and both reach
        `Apps::Libraries::SpacesController#toggle_enabled`. Every surface performs
        the transition through the shared `Libraries::EnablementService`, so none
        can drift on what enabling means, who may do it, or what the confirmation
        says.

        Enabling restores the library to **every browse, search and mobile
        surface**. Nothing else about it changes — disabling never touched its
        categories, items or audience rules, so there is nothing to restore
        beyond the flag.

        ### Idempotent, deliberately NOT a toggle

        A button can safely mean "flip it" because a person sees the result; a
        network client retries, and a retried toggle undoes itself. So this API
        splits the web's single control into two verbs that each name the state
        they move **to**.

        Enabling an already-enabled library is a no-op that still answers `200`:
        it writes nothing, leaves `updated_at` untouched, and reports
        `changed: false`. Ten retries are indistinguishable from one call. Read
        `changed` to know whether this call was the one that moved the flag.

        ### Authorization — three gates

        Identical to `POST /libraries/{id}/disable`, and deliberately so: ONE
        capability governs both directions, which means a caller who may take a
        library down may always put it back.

        1. **Token scope `write:libraries`.** The scope IS grantable — it reaches
           `ApiToken::AVAILABLE_SCOPES` through `Mcp::ScopeRegistry` and the
           mobile login paths already mint it. `has_scope?` fails **closed** on a
           scopeless token, and the blanket `admin` scope satisfies it. A
           session-authenticated caller (native WebView, internal call) carries no
           token and passes, exactly as `require_scope` defines it.
        2. **Libraries app access** — enablement (explicit row OR pricing tier OR
           active subscription) AND the app's per-group visibility. Otherwise
           `403 access_denied`.
        3. **Libraries administrator** — a Libraries admin or a place-limited
           one, precisely what the index's `can_disable` flag reports. Business
           admins, super admins, **per-app Libraries admins** and place-limited
           Libraries admins qualify; a manager with no Libraries grant does not.
           Otherwise `403 forbidden`.
        4. **This library** — `Libraries::Access#can_toggle_library?`, the
           web's `toggle_enabled` gate: a place-limited admin only for a library
           in their reach. Otherwise `403 forbidden`.

        Gate 3 is **app-wide, not per-library**, and is answered BEFORE the
        library is resolved. So a caller with no Libraries admin grant gets
        `403` for a real id and for a nonexistent one alike, and cannot
        enumerate library ids by reading `403`-vs-`404`. A library belonging to
        another tenant is `404`, never `403` — a `403` would confirm the id is
        real somewhere.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The library is enabled. Returned whether this call changed
            it or it was already on — read `changed` to tell those apart.
          content:
            application/json:
              schema:
                type: object
                required:
                - library
                - changed
                - message
                properties:
                  library:
                    allOf:
                    - "$ref": "#/components/schemas/LibraryCard"
                    description: The library AFTER the call, in the same card shape
                      `GET /libraries/list` emits — counts included — so a client
                      redraws the row from this one response with no follow-up. `enabled`
                      is always `true` here.
                  changed:
                    type: boolean
                    description: "`true` when this call moved the flag; `false` when
                      the library was already enabled and nothing was written."
                    example: true
                  message:
                    type: string
                    description: Confirmation copy, byte-identical to the web flash
                      — it describes what is TRUE now, so it reads the same on a retry.
                    enum:
                    - Library enabled successfully.
                    example: Library enabled successfully.
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token (`authentication_required`).
        '403':
          description: The token lacks `write:libraries` (`insufficient_permissions`);
            the Libraries app is not enabled for the tenant or the caller is outside
            its audience (`access_denied`); or the caller is not a Libraries administrator
            (`forbidden`). Nothing is written in any of the three.
        '404':
          description: No library with that id exists in the caller's tenant (`not_found`).
            Another tenant's library answers this too — never `403`.
        '422':
          description: The library could not be saved (`invalid`). The `message` names
            the validation that refused it.
  "/libraries/items/{id}/bookmark":
    parameters:
    - name: id
      in: path
      required: true
      description: The library ITEM id (not the library, and not the category).
      schema:
        type: integer
    post:
      tags:
      - Libraries
      summary: Bookmark a library item for the current user
      description: |
        Saves the item for the **calling user**, writing the platform-wide
        `Platform::Bookmark` shared with the web item kebab's **Bookmark this**
        entry, the `/bookmarks` page and My Stuff. An item saved on the phone is
        saved on the web, and vice versa — there is no app-local "library item
        pin".

        Bookmarks in Libraries are **items-only**: never a library, never a
        category. That is why the path carries an explicit `items/` segment
        rather than hanging off a library id.

        **Idempotent** — bookmarking an already-bookmarked item is a no-op that
        still returns `bookmarked: true` and never creates a second row. This is
        the deliberate divergence from the web control, which is a single
        *toggle* because one button serves both directions: a retried `POST`
        here must never silently un-bookmark. Read the saved set back with
        `GET /libraries/bookmarks`.

        * **Authorization — anyone who can OPEN the item may bookmark it.**
          Requires the `write:libraries` scope (the floor every write in this
          namespace applies) and Libraries-app access. The item is then resolved
          inside the caller's visible scope: visibility is inherited from the
          parent library's audience, so a restricted library, a disabled one, a
          cross-tenant id and a missing id all return `404` alike — none of them
          reveals that the item exists.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Item bookmarked (or already bookmarked).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LibraryItemBookmarkState"
        '401':
          description: Missing or invalid API token
        '403':
          description: Missing the `write:libraries` scope (`insufficient_permissions`),
            or the Libraries app is not accessible to the caller (`access_denied`).
        '404':
          description: No library item with that id is visible to the caller (`not_found`).
            An item whose library the caller is in the audience of but which has been
            switched off answers `library_disabled` instead, with the message "\"<library
            name>\" has been disabled and isn't available right now."
    delete:
      tags:
      - Libraries
      summary: Remove the current user's bookmark on a library item
      description: |
        Removes the calling user's bookmark on the item. **Idempotent** —
        removing a bookmark that isn't there is a no-op that still returns
        `bookmarked: false`.

        A bookmark carrying a note is soft-deleted (recoverable from the
        platform Trash); a bare one is removed outright. That asymmetry is the
        web toggle's, and it exists so routine un-saves don't bury the things
        Trash is for.

        * **Authorization is deliberately ASYMMETRIC with the POST verb.** An
          **already-saved** bookmark can always be cleared — even once the item
          has gone out of reach, because its library was disabled or its
          audience narrowed. `GET /libraries/bookmarks` drops such rows from the
          list (listing them would leak the title of content the caller cannot
          open), so this verb is the only way they can be cleared and must not
          refuse them. Removing something that is **neither bookmarked nor
          visible** still returns `404`, so the verb never becomes a way to
          probe which item ids exist. Scope and app-access gates are unchanged.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Bookmark removed (or there was none).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LibraryItemBookmarkState"
        '401':
          description: Missing or invalid API token
        '403':
          description: Missing the `write:libraries` scope (`insufficient_permissions`),
            or the Libraries app is not accessible to the caller (`access_denied`).
        '404':
          description: The caller has no bookmark on that item AND no library item
            with that id is visible to them (`not_found`, or `library_disabled` when
            only the library's off switch keeps them out — see `POST`).
  "/libraries/bookmarks":
    get:
      tags:
      - Libraries
      summary: The caller's saved (bookmarked) library items
      description: |
        The library items the calling user has bookmarked — most recently saved
        first — plus the per-library groups needed to render them grouped.

        Returns `200` with an empty `items` array (never an error) when the
        caller has saved nothing.

        See the file header for ordering, the exclusion rules, the authorization
        model, and the fields that are deliberately absent.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        description: 1-based page number. A missing, zero, negative or non-numeric
          value is treated as page 1. A client that array-wraps its query params (`?page[]=2`)
          is honoured as the scalar rather than refused.
        schema:
          type: integer
          minimum: 1
          default: 1
        example: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page, clamped to 1..50. A value that does not parse
          as a positive integer is treated as ABSENT and falls back to 20 — it is
          NOT clamped up to 1.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
        example: 20
      responses:
        '200':
          description: Saved items retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - libraries
                - groups_total
                - meta
                properties:
                  items:
                    type: array
                    description: This page of saved items, most recently saved first.
                    items:
                      "$ref": "#/components/schemas/LibraryBookmarkedItem"
                  libraries:
                    type: array
                    description: |-
                      One row per library the caller has saved something in, with the count of their saved items in it. Counted over the whole saved set (not the page), ordered by count descending then name, and containing no library the caller cannot see.
                      **PAGE 1 ONLY.** This array does not paginate — it describes the whole saved set — so it is sent once and every later page answers `[]`. An empty array is therefore not a statement about how much the caller has saved: read `groups_total`, which is present on every page. See the file header for the measurement behind this.
                    items:
                      "$ref": "#/components/schemas/LibraryBookmarkGroup"
                  groups_total:
                    type: integer
                    description: |-
                      How many library groups the caller's saved set spans — present and complete on EVERY page, including the pages where `libraries` is `[]`.
                      This is the field that makes the page-1-only contract readable: `libraries: []` with `groups_total > 0` means "already sent on page 1", and with `groups_total == 0` means nothing is saved in a library the caller can open. A client that instead took `libraries.length` as the group count would report zero groups for every page after the first. Saves in libraries the caller can no longer open are not counted here; see `hidden_count`.
                    example: 2
                  hidden_count:
                    type: integer
                    description: How many of the caller's saved items still exist
                      but sit in a library they can no longer open (its audience narrowed,
                      or it was disabled). A count only; the items themselves are
                      never listed. Present on every page.
                    example: 0
                  hidden_note:
                    type: string
                    nullable: true
                    description: The sentence the web and phone Bookmarked lists print
                      for `hidden_count` ("2 saved items are in libraries you can
                      no longer open."); null when `hidden_count` is 0.
                    example:
                  meta:
                    "$ref": "#/components/schemas/LibraryBookmarkPagination"
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
              examples:
                saved:
                  summary: Two saved items across two libraries — and the two shapes
                    of `icon`
                  description: 'The first row is `icon_type: default`, so `icon` is
                    a Font Awesome class. The second is `icon_type: emoji`, so `icon`
                    is a raw emoji GRAPHEME — pass it to an icon font and the row
                    renders tofu. Switch on `icon_type`, never on the string''s shape.'
                  value:
                    items:
                    - id: 412
                      title: Remote Work Policy
                      description: How and when you can work remotely
                      item_type: file
                      item_type_label: PDF
                      icon_type: default
                      icon: fas fa-file-pdf
                      icon_color: "#c0392b"
                      icon_background: "#fbe7e5"
                      format: pdf
                      library:
                        id: 7
                        name: Company Policies
                        icon: fas fa-shield-halved
                        color: "#3478f6"
                        enabled: true
                      category:
                        id: 21
                        name: Leave & Time Off
                        position: 0
                      breadcrumb: Company Policies › Leave & Time Off
                      status: available
                      status_label:
                      url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf
                      form_id:
                      copy_link_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf
                      link_url:
                      opens_in: new_tab
                      open_mode: preview
                      download_url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf?disposition=attachment
                      media:
                        filename: remote-work.pdf
                        content_type: application/pdf
                        byte_size: 1153434
                        size_label: 1.1 MB
                        width:
                        height:
                        dimensions:
                        duration_seconds:
                        duration_label:
                        url: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf/remote-work.pdf
                      created_by:
                        id: 88
                        name: Neha Kulkarni
                      created_at: '2026-03-12T09:14:00Z'
                      updated_by:
                      updated_at: '2026-08-30T11:02:00Z'
                      can_manage: false
                      can_delete: false
                      bookmarked: true
                      bookmark:
                        id: 9021
                        note: Read before the audit
                        created_at: '2026-08-31T16:40:12Z'
                    - id: 512
                      title: Corporate card portal
                      description: Statements, limits and dispute forms
                      item_type: simple_link
                      item_type_label: Link
                      icon_type: emoji
                      icon: "\U0001F4B3"
                      icon_color: "#11936f"
                      icon_background: "#d4efe7"
                      format:
                      library:
                        id: 9
                        name: Brand Assets
                        icon: fas fa-palette
                        color: "#8b5cf6"
                        enabled: true
                      category:
                        id: 33
                        name: Finance
                        position: 2
                      breadcrumb: Brand Assets › Finance
                      status: available
                      status_label:
                      url: https://intranet.acme.com/cards
                      form_id:
                      copy_link_url: https://intranet.acme.com/cards
                      link_url: https://intranet.acme.com/cards
                      opens_in: new_tab
                      open_mode: external
                      download_url:
                      media:
                      created_by:
                        id: 91
                        name: Rajveer Sandhu
                      created_at: '2026-07-27T10:00:00Z'
                      updated_by:
                      updated_at: '2026-08-18T10:00:00Z'
                      can_manage: true
                      can_delete: true
                      bookmarked: true
                      bookmark:
                        id: 9020
                        note:
                        created_at: '2026-08-29T08:11:04Z'
                    libraries:
                    - id: 7
                      title: Company Policies
                      icon: fas fa-shield-halved
                      color: "#3478f6"
                      library_type: mixed_content
                      enabled: true
                      bookmarked_count: 1
                    - id: 9
                      title: Brand Assets
                      icon: fas fa-palette
                      color: "#8b5cf6"
                      library_type: mixed_content
                      enabled: true
                      bookmarked_count: 1
                    groups_total: 2
                    hidden_count: 0
                    hidden_note:
                    meta:
                      total_count: 2
                      total_pages: 1
                      current_page: 1
                      per_page: 20
                      has_next_page: false
                      has_prev_page: false
                saved_form:
                  summary: A saved FORM item — opened by id, not by URL
                  description: "`open_mode: form` and `url: null`; the locator is
                    `form_id`. `copy_link_url` is still a real URL, because that is
                    what the clipboard needs."
                  value:
                    items:
                    - id: 618
                      title: Expense Claim
                      description: Submit receipts for reimbursement
                      item_type: form
                      item_type_label: Form
                      icon_type: default
                      icon: fas fa-clipboard-list
                      icon_color: "#9a5b06"
                      icon_background: "#fbeecd"
                      format:
                      library:
                        id: 7
                        name: Company Policies
                        icon: fas fa-shield-halved
                        color: "#3478f6"
                        enabled: true
                      category:
                        id: 24
                        name: Finance
                        position: 1
                      breadcrumb: Company Policies › Finance
                      status: available
                      status_label:
                      url:
                      form_id: 583
                      copy_link_url: https://acme.workforce.mangoapps.com/apps/forms/templates/583
                      link_url:
                      opens_in: new_tab
                      open_mode: form
                      download_url:
                      media:
                      created_by:
                        id: 88
                        name: Neha Kulkarni
                      created_at: '2026-05-04T08:30:00Z'
                      updated_by:
                      updated_at: '2026-05-04T08:30:00Z'
                      can_manage: false
                      can_delete: false
                      bookmarked: true
                      bookmark:
                        id: 9033
                        note:
                        created_at: '2026-09-01T12:05:00Z'
                    libraries:
                    - id: 7
                      title: Company Policies
                      icon: fas fa-shield-halved
                      color: "#3478f6"
                      library_type: mixed_content
                      enabled: true
                      bookmarked_count: 1
                    groups_total: 1
                    hidden_count: 0
                    hidden_note:
                    meta:
                      total_count: 1
                      total_pages: 1
                      current_page: 1
                      per_page: 20
                      has_next_page: false
                      has_prev_page: false
                empty:
                  summary: Nothing saved yet
                  value:
                    items: []
                    libraries: []
                    groups_total: 0
                    hidden_count: 0
                    hidden_note:
                    meta:
                      total_count: 0
                      total_pages: 0
                      current_page: 1
                      per_page: 20
                      has_next_page: false
                      has_prev_page: false
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:libraries`;
            or `access_denied` — the Libraries app is not accessible to this caller."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - insufficient_permissions
                        - access_denied
                      message:
                        type: string
              examples:
                no_scope:
                  value:
                    error:
                      code: insufficient_permissions
                      message: 'Required scope: read:libraries'
                no_app:
                  value:
                    error:
                      code: access_denied
                      message: You do not have access to the Libraries app
  "/sites/sites/{site_id}/follow":
    parameters:
    - name: site_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: Whether the caller follows a site
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Follow state
          content:
            application/json:
              schema:
                type: object
                properties:
                  following:
                    type: boolean
                  follows_enabled:
                    type: boolean
                    description: The tenant's `enable_site_follows` switch. When false,
                      POST is refused.
                  site:
                    "$ref": "#/components/schemas/SitesRef"
              example:
                following: true
                follows_enabled: true
                site:
                  id: 12
                  name: People Team
        '404':
          description: "`not_found` — no such site in this organization, or one the
            caller cannot open."
    post:
      tags:
      - Sites
      summary: Follow a site
      description: Be notified when pages are published in this site. Idempotent.
        Refused with 403 `follows_disabled` when the tenant has follows switched off,
        and 404 `not_found` when the caller cannot open the site.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A retried request with the same key replays the first answer
          instead of writing again.
        schema:
          type: string
      responses:
        '201':
          description: Following (new or existing follow)
          content:
            application/json:
              schema:
                type: object
                properties:
                  following:
                    type: boolean
                    example: true
                  followed_at:
                    type: string
                    format: date-time
                  site:
                    "$ref": "#/components/schemas/SitesRef"
        '403':
          description: "`follows_disabled`."
        '404':
          description: "`not_found` — no such site, or one the caller cannot open."
    delete:
      tags:
      - Sites
      summary: Stop following a site
      description: Removes the caller's own follow. Never gated on access or on the
        follows switch, and answers the same whether or not a follow existed.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A retried request with the same key replays the first answer
          instead of writing again.
        schema:
          type: string
      responses:
        '200':
          description: Not following
          content:
            application/json:
              schema:
                type: object
                properties:
                  following:
                    type: boolean
                    example: false
                  site_id:
                    type: integer
  "/sites/pages/{page_id}/watch":
    parameters:
    - name: page_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: Whether the caller watches a page
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Watch state
          content:
            application/json:
              schema:
                type: object
                properties:
                  watching:
                    type: boolean
                  follows_enabled:
                    type: boolean
                  page:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
        '403':
          description: "`access_denied` — the caller cannot open this page. A caller
            who cannot know the page exists (outside its site or its audience, or
            a draft never published to them, and not its approver) gets 404 `not_found`
            instead, as for an unknown id."
        '404':
          description: "`not_found`."
    post:
      tags:
      - Sites
      summary: Watch a page
      description: Be notified when this page is updated. Idempotent. Needs the page
        open to the caller (site access, the page's status and its audience) and the
        tenant's `enable_site_follows` switch on.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A retried request with the same key replays the first answer
          instead of writing again.
        schema:
          type: string
      responses:
        '201':
          description: Watching (new or existing watch)
          content:
            application/json:
              schema:
                type: object
                properties:
                  watching:
                    type: boolean
                    example: true
                  watched_at:
                    type: string
                    format: date-time
                  page:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
        '403':
          description: "`follows_disabled` or `access_denied`."
        '404':
          description: "`not_found`."
    delete:
      tags:
      - Sites
      summary: Stop watching a page
      description: Removes the caller's own watch. Never gated on access or on the
        follows switch.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A retried request with the same key replays the first answer
          instead of writing again.
        schema:
          type: string
      responses:
        '200':
          description: Not watching
          content:
            application/json:
              schema:
                type: object
                properties:
                  watching:
                    type: boolean
                    example: false
                  page_id:
                    type: integer
  "/sites/pages/{page_id}/comments":
    parameters:
    - name: page_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: A page's comment thread
      description: Top-level comments, oldest first, each with its replies nested
        (one level — replies cannot be replied to). Needs the page open to the caller
        and the tenant's `enable_comments` switch on (403 `comments_disabled`).
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Comments
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SitesComment"
                    meta:
                      type: object
                      description: Pagination over TOP-LEVEL comments, plus `comment_count`
                        — every comment on the page, replies included.
                      properties:
                        total_count:
                          type: integer
                        total_pages:
                          type: integer
                        current_page:
                          type: integer
                        per_page:
                          type: integer
                        has_next_page:
                          type: boolean
                        has_prev_page:
                          type: boolean
                        comment_count:
                          type: integer
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: "`access_denied` or `comments_disabled`."
        '404':
          description: "`not_found`."
    post:
      tags:
      - Sites
      summary: Comment on a page, or reply to a comment
      description: 'Posts through the same writer as the desktop and phone forms,
        so it notifies the same people: the page''s author, recent commenters on the
        page, and anyone @mentioned who can open the page. A mention is `@[Name](mention:<user
        id>)` (what the web composer''s @ picker writes), or typed "@First Last" /
        "@name@example.com". `parent_id` makes it a reply; it must be a top-level
        comment on the same page.'
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A retried request with the same key replays the first answer
          instead of writing again.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 10000
                parent_id:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/SitesComment"
        '403':
          description: "`access_denied` or `comments_disabled`."
        '404':
          description: "`not_found`."
        '409':
          description: "`idempotency_conflict` — this `Idempotency-Key` was already
            used with a different body; or `idempotency_in_progress` — a request with
            this key is still running (retry after the `Retry-After` seconds to get
            its answer replayed). Both in the `error` envelope."
        '422':
          description: Two shapes. A blank `body` answers the `error` envelope with
            code `invalid_body`. A body the comment itself refuses — longer than 10,000
            characters, a `parent_id` that is a reply, or one on another page — answers
            an `errors` list, one entry per problem, each with code `validation_error`.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  required:
                  - error
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          example: invalid_body
                        message:
                          type: string
                          example: body can't be blank
                - type: object
                  required:
                  - errors
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            example: parent_id
                          message:
                            type: string
                          code:
                            type: string
                            example: validation_error
  "/sites/pages/{page_id}/comments/{id}":
    parameters:
    - name: page_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment's id (a top-level comment or a reply) on this page.
      schema:
        type: integer
    patch:
      tags:
      - Sites
      summary: Edit a comment
      description: |-
        The web's Edit on a comment. The comment's author alone — nobody edits someone else's words; offered where the comment's `can_edit` is true. Stamps `edited_at`, and notifies only the people the edit newly @mentions (anyone the comment already named is not told again). `PUT` is accepted too.

        Refusals, in order: a page outside the caller's company, or a comment not on this page, is 404 `not_found`; a page the caller cannot open is 403 `access_denied` (404 `not_found` when they cannot know it exists); comments switched off is 403 `comments_disabled`; anyone but the author is 403 `not_allowed`; a blank body is 422 `invalid_body`; a body the comment refuses (over 10,000 characters) is 422 with an `errors` list.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 10000
                  description: The new body. Mentions as for POST.
      responses:
        '200':
          description: Edited
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/SitesComment"
                  warnings:
                    type: array
                    description: Present when a typed "@First Last" names several
                      colleagues and so reached nobody.
                    items:
                      type: string
        '401':
          description: Missing or invalid token.
        '403':
          description: "`access_denied`, `comments_disabled` or `not_allowed`; a token
            without `write:sites` is refused too."
        '404':
          description: "`not_found` — the page or the comment."
        '422':
          description: A blank `body` answers the `error` envelope with code `invalid_body`;
            a body the comment itself refuses answers an `errors` list, each entry
            with code `validation_error`.
    delete:
      tags:
      - Sites
      summary: Delete a comment
      description: |-
        The web's Delete on a comment. Its author, a company admin or Sites app-admin, or an owner or editor of the page's site (who moderate what appears on it); offered where the comment's `can_delete` is true. Deleting a top-level comment deletes its replies with it. Unlike the other comment calls this does not need the tenant's comments switched on, as on the web, so a moderator can still remove a comment after comments went off. A retried DELETE with the same `Idempotency-Key` replays the first answer.

        Refusals, in order: a page outside the caller's company, or a comment not on this page (or already deleted), is 404 `not_found`; a page the caller cannot open is 403 `access_denied` (404 `not_found` when they cannot know it exists); anyone else is 403 `not_allowed`.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Comment removed
                  id:
                    type: integer
                    description: The deleted comment's id.
                  comment_count:
                    type: integer
                    description: Every comment left on the page, replies included.
        '401':
          description: Missing or invalid token.
        '403':
          description: "`access_denied` or `not_allowed`; a token without `write:sites`
            is refused too."
        '404':
          description: "`not_found` — the page or the comment."
  "/sites/following":
    get:
      tags:
      - Sites
      summary: Sites the caller follows
      description: Most recently followed first; only sites the caller can still open.
        403 `follows_disabled` when the tenant has follows switched off.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Followed sites
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          followed_at:
                            type: string
                            format: date-time
                          site:
                            "$ref": "#/components/schemas/SitesListSiteRow"
                    meta:
                      "$ref": "#/components/schemas/SitesPagination"
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: "`follows_disabled`."
  "/sites/watching":
    get:
      tags:
      - Sites
      summary: Pages the caller watches
      description: Most recently watched first; only pages the caller can still open.
        403 `follows_disabled` when the tenant has follows switched off.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Watched pages
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          watched_at:
                            type: string
                            format: date-time
                          page:
                            "$ref": "#/components/schemas/SitesPageSummary"
                    meta:
                      "$ref": "#/components/schemas/SitesPagination"
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: "`follows_disabled`."
  "/sites/to_confirm":
    get:
      tags:
      - Sites
      summary: To confirm — must-read pages waiting on the caller
      description: |-
        The "To confirm" tab of Saved & following (the web's `/apps/sites/bookmarks?tab=confirm` and its phone twin, loaded the same way): pages whose live version asked the caller to confirm they have read it, and that they have not confirmed, newest ask first. Only pages the caller can open now; sample pages never ask.

        Each row is the page and its `read_confirmation` — the same block `GET /sites/pages/{id}` carries: the version asking, the caller's own confirm-by date (`due_on`; null when the ask has no date) and whether it is `overdue`. The web row reads "in <site> · version N · confirm by <date>", or an Overdue badge past it. The list never confirms: open the page and `POST /sites/pages/{page_id}/acknowledgement`; the page then leaves this list. Always answered — a must-read ask has no tenant switch. `total_count` is the number the tab's label carries.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Pages waiting on the caller's confirmation
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          page:
                            "$ref": "#/components/schemas/SitesPageSummary"
                          version_published_at:
                            type: string
                            format: date-time
                            description: When the version asking was published.
                          read_confirmation:
                            type: object
                            description: 'Whether the live version of a page asks
                              THIS caller to confirm reading it. `{ requested: false
                              }` when it does not.'
                            properties:
                              requested:
                                type: boolean
                              acknowledged:
                                type: boolean
                              acknowledged_at:
                                type: string
                                format: date-time
                                nullable: true
                              version_number:
                                type: integer
                              due_on:
                                type: string
                                format: date
                                nullable: true
                              overdue:
                                type: boolean
                    meta:
                      "$ref": "#/components/schemas/SitesPagination"
              example:
                items:
                - page:
                    id: 812
                    title: Forklift safety
                    display_title: Forklift safety
                    slug: forklift-safety
                    page_type: content
                    status: published
                    published_at: '2026-10-07T14:00:00Z'
                    site:
                      id: 41
                      name: New York Office
                      display_name: New York Office
                      slug: new-york-office
                      site_type: location
                      status: published
                      icon: fa-store
                      icon_color:
                      tile_color: "#0f7b8a"
                  version_published_at: '2026-10-07T14:00:00Z'
                  read_confirmation:
                    requested: true
                    acknowledged: false
                    acknowledged_at:
                    version_number: 4
                    due_on: '2026-10-14'
                    overdue: true
                meta:
                  total_count: 1
                  total_pages: 1
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                total_count: 1
        '304':
          "$ref": "#/components/responses/NotModified"
  "/sites/recently_read":
    get:
      tags:
      - Sites
      summary: Recent — pages the caller opened, and what changed in their sites
      description: |-
        The Sites Recent screen as data (the web's Sites › Recent and its phone twin, loaded the same way), in two sections.

        `items` — "Pages you opened": the caller's latest read of each page in the last 90 days, newest first — one row per page however often it was opened, paged. A read is recorded whenever the caller opens a page on the web, on the phone, or through `GET /sites/pages/{id}` (not when an editor is served their unpublished draft there).

        `updated_in_your_sites` — "Updated in sites for you": up to 5 pages that changed for readers in the last 14 days on the caller's own sites (a member of it, following it, or one aimed at them), newest change first, each saying how it changed (`change.kind`) and, on a republish, the author's "What changed?" note. Unpaged — the same rows on every page of `items`, as the web draws them.

        Both sections list only pages the caller can open now, so each role sees its own rows from the same request: an employee the pages their audience admits, a site editor also their sites' drafts (`status: draft`), a Sites admin every site. Each row's site carries its icon, the colour its tile paints (`tile_color`) and its parent site — named only when the caller may open that parent. Reading history is recorded only while the tenant's Page Analytics (`enable_analytics`) is on; with it off this answers 403 `analytics_disabled` rather than an empty list.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Recently read pages
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          last_read_at:
                            type: string
                            format: date-time
                          page:
                            "$ref": "#/components/schemas/SitesRecentPage"
                    meta:
                      "$ref": "#/components/schemas/SitesPagination"
                    updated_in_your_sites:
                      type: array
                      description: What changed lately in the caller's own sites (at
                        most 5, the last 14 days), newest change first.
                      items:
                        type: object
                        properties:
                          changed_at:
                            type: string
                            format: date-time
                            description: When a reader last saw the page change —
                              its last publish when live, else its last edit.
                          change:
                            type: object
                            properties:
                              kind:
                                type: string
                                enum:
                                - published
                                - republished
                                - updated
                                description: "`published` — the page went live (its
                                  first publish); `republished` — published again;
                                  `updated` — not live, edited (a draft only its editors
                                  open)."
                              note:
                                type: string
                                nullable: true
                                description: The author's "What changed?" note on
                                  a republish; null when they wrote none, and on every
                                  other kind.
                          page:
                            "$ref": "#/components/schemas/SitesRecentPage"
              example:
                items:
                - last_read_at: '2026-10-17T09:12:00Z'
                  page:
                    id: 812
                    title: Forklift safety
                    display_title: Forklift safety
                    slug: forklift-safety
                    page_type: content
                    published_at: '2026-10-07T14:00:00Z'
                    status: published
                    updated_at: '2026-10-07T14:00:00Z'
                    site:
                      id: 41
                      name: New York Office
                      display_name: New York Office
                      slug: new-york-office
                      site_type: location
                      status: published
                      icon: fa-store
                      icon_color:
                      tile_color: "#0f7b8a"
                      parent:
                        id: 7
                        name: Operations
                        display_name: Operations
                meta:
                  total_count: 1
                  total_pages: 1
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                total_count: 1
                updated_in_your_sites:
                - changed_at: '2026-10-07T14:00:00Z'
                  change:
                    kind: republished
                    note: Added the battery-charge check
                  page:
                    id: 812
                    title: Forklift safety
                    display_title: Forklift safety
                    slug: forklift-safety
                    page_type: content
                    published_at: '2026-10-07T14:00:00Z'
                    status: published
                    updated_at: '2026-10-07T14:00:00Z'
                    site:
                      id: 41
                      name: New York Office
                      display_name: New York Office
                      slug: new-york-office
                      site_type: location
                      status: published
                      icon: fa-store
                      icon_color:
                      tile_color: "#0f7b8a"
                      parent:
                        id: 7
                        name: Operations
                        display_name: Operations
                unread_notification_count: 3
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: "`analytics_disabled`."
  "/sites/pages/{page_id}/acknowledgement":
    parameters:
    - name: page_id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Sites
      summary: Confirm you have read a must-read page
      description: 'Records the caller''s "I''ve read and understood this" for the
        version of the page that is asking them. Idempotent: a repeat answers 201
        with the same `acknowledged_at`. Honours `Idempotency-Key`.'
      security:
      - BearerAuth: []
      responses:
        '201':
          description: Confirmed (or already confirmed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  read_confirmation:
                    "$ref": "#/components/schemas/SitesReadConfirmation"
                  page:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
              example:
                read_confirmation:
                  requested: true
                  acknowledged: true
                  acknowledged_at: '2026-09-23T14:05:00Z'
                  version_number: 7
                  due_on: '2026-09-30'
                  overdue: false
                page:
                  id: 1617
                  title: Fire evacuation procedure
        '403':
          description: "`access_denied` — the caller cannot open this page."
        '404':
          description: "`not_found` — no such page in this organization."
        '409':
          description: "`save_failed` — the confirmation could not be recorded; retry."
        '422':
          description: "`not_asked` — the live version of this page is not asking
            the caller to confirm (no ask on the page, a republish since the client
            read it, or the caller was not among the readers when it was published)."
  "/sites/pages/{page_id}/reactions":
    parameters:
    - name: page_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: Who reacted to a page
      description: "One row per reaction, newest first — a person who left \U0001F44D
        and ❤️ is a row under each — with the person who left it. `summary` describes
        the whole page and is never narrowed by `emoji` or the page of rows: draw
        the sheet's tabs from it (\"All\" = `summary.total`, then one tab per `summary.counts`
        entry) and pass a tab's `emoji` back to list its rows."
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: false
        description: "One tab — the reaction's glyph (`\U0001F44D`) or its catalog
          key (`like`)."
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The reactions and the page's reaction summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  page_id:
                    type: integer
                  summary:
                    "$ref": "#/components/schemas/SitesReactionSummary"
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SitesReactionRow"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Reactions matching `emoji` (all of them without
                          it).
                      current_page:
                        type: integer
                      total_pages:
                        type: integer
                      per_page:
                        type: integer
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
              example:
                page_id: 1617
                summary:
                  total: 3
                  counts:
                  - emoji: "\U0001F44D"
                    key: like
                    label: Like
                    icon_url: https://officechat.workforce.mangoapps.com/assets/reactions/like-1a2b.svg
                    count: 2
                  - emoji: "\U0001F4A1"
                    key: insightful
                    label: Insightful
                    icon_url: https://officechat.workforce.mangoapps.com/assets/reactions/insightful-3c4d.svg
                    count: 1
                  mine:
                  - "\U0001F44D"
                  available:
                  - emoji: "\U0001F44D"
                    key: like
                    label: Like
                    icon_url: https://officechat.workforce.mangoapps.com/assets/reactions/like-1a2b.svg
                reactions:
                - emoji: "\U0001F4A1"
                  key: insightful
                  label: Insightful
                  icon_url: https://officechat.workforce.mangoapps.com/assets/reactions/insightful-3c4d.svg
                  id: 90412
                  reacted_at: '2026-10-20T09:12:00Z'
                  user:
                    id: 214
                    name: Priya Shah
                    initials: PS
                    job_title: Store Manager
                    avatar_url: https://officechat.workforce.mangoapps.com/rails/active_storage/representations/…
                meta:
                  total_count: 3
                  current_page: 1
                  total_pages: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
        '401':
          description: No or invalid token.
        '403':
          description: "`access_denied` — the caller cannot open this page; `insufficient_permissions`
            — the token lacks `read:sites`."
        '404':
          description: "`not_found` — no such page in this organization."
    post:
      tags:
      - Sites
      summary: React to a page
      description: 'Adds the caller''s reaction. Idempotent: 201 when this call added
        it, 200 when the caller already had it (nothing changes). A new reaction must
        be one the company offers (`reactions.available` on the page).'
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: "The reaction's glyph (`\U0001F44D`) or its catalog
                    key (`like`)."
                  example: "\U0001F44D"
      responses:
        '201':
          description: Added
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesReactionWrite"
        '200':
          description: The caller already had this reaction
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesReactionWrite"
        '403':
          description: "`access_denied` — the caller cannot open this page; `insufficient_permissions`
            — the token lacks `write:sites`."
        '404':
          description: "`not_found` — no such page in this organization."
        '422':
          description: "`emoji_required` — no emoji; `invalid_emoji` — not a reaction
            the company offers (`error.details.allowed` lists the ones it does)."
    delete:
      tags:
      - Sites
      summary: Remove your reaction from a page
      description: 'Removes the caller''s own reaction with this emoji. Idempotent:
        removing one the caller does not hold answers 200 with `removed: false`. A
        reaction the company no longer offers can still be removed.'
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: true
        description: "The reaction's glyph (`\U0001F44D`) or its catalog key (`like`)."
        schema:
          type: string
      responses:
        '200':
          description: Removed (or was not there)
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/SitesReactionWrite"
                - type: object
                  properties:
                    removed:
                      type: boolean
                      description: False when the caller held no such reaction.
        '403':
          description: "`access_denied` — the caller cannot open this page; `insufficient_permissions`
            — the token lacks `write:sites`."
        '404':
          description: "`not_found` — no such page in this organization."
        '422':
          description: "`emoji_required` — no emoji."
  "/sites/pages/{page_id}/comments/{comment_id}/reactions":
    parameters:
    - name: page_id
      in: path
      required: true
      schema:
        type: integer
    - name: comment_id
      in: path
      required: true
      description: A comment or reply on this page.
      schema:
        type: integer
    post:
      tags:
      - Sites
      summary: React to a page comment
      description: 'Adds the caller''s reaction to a comment or reply. Idempotent:
        201 when this call added it, 200 when the caller already had it (nothing changes).
        A new reaction must be one the company offers (`reactions.available` in the
        answer).'
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: "The reaction's glyph (`\U0001F44D`) or its catalog
                    key (`like`)."
                  example: "\U0001F44D"
      responses:
        '201':
          description: Added
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesCommentReactionWrite"
              example:
                page_id: 1617
                comment_id: 5120
                emoji: "\U0001F44D"
                reacted: true
                reactions:
                  total: 2
                  counts:
                  - emoji: "\U0001F44D"
                    key: like
                    label: Like
                    icon_url: https://officechat.workforce.mangoapps.com/assets/reactions/like-1a2b.svg
                    count: 2
                  mine:
                  - "\U0001F44D"
                  available:
                  - emoji: "\U0001F44D"
                    key: like
                    label: Like
                    icon_url: https://officechat.workforce.mangoapps.com/assets/reactions/like-1a2b.svg
        '200':
          description: The caller already had this reaction
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesCommentReactionWrite"
        '403':
          description: "`access_denied` — the caller cannot open this page; `comments_disabled`
            — comments are off for the organization; `insufficient_permissions` —
            the token lacks `write:sites`."
        '404':
          description: "`not_found` — no such page in this organization, or no such
            comment on it."
        '422':
          description: "`emoji_required` — no emoji; `invalid_emoji` — not a reaction
            the company offers (`error.details.allowed` lists the ones it does)."
    delete:
      tags:
      - Sites
      summary: Remove your reaction from a page comment
      description: 'Removes the caller''s own reaction with this emoji from a comment
        or reply. Idempotent: removing one the caller does not hold answers 200 with
        `removed: false`. A reaction the company no longer offers can still be removed.'
      security:
      - BearerAuth: []
      parameters:
      - name: emoji
        in: query
        required: true
        description: "The reaction's glyph (`\U0001F44D`) or its catalog key (`like`)."
        schema:
          type: string
      responses:
        '200':
          description: Removed (or was not there)
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/SitesCommentReactionWrite"
                - type: object
                  properties:
                    removed:
                      type: boolean
                      description: False when the caller held no such reaction.
        '403':
          description: "`access_denied` — the caller cannot open this page; `comments_disabled`
            — comments are off for the organization; `insufficient_permissions` —
            the token lacks `write:sites`."
        '404':
          description: "`not_found` — no such page in this organization, or no such
            comment on it."
        '422':
          description: "`emoji_required` — no emoji."
  "/sites/pages/{id}/mark_accurate":
    parameters:
    - name: id
      in: path
      required: true
      description: The page's id.
      schema:
        type: integer
    post:
      tags:
      - Sites
      summary: Mark a page as still accurate
      description: |-
        Says the page was checked today and is still right — the web's "Still accurate" (a Going stale row, the page's "Not checked in …" panel) and "Mark as still accurate" (the page's ⋮ menu). Readers see "Checked <date>" on the page's foot line, and it is off the Going stale list for the company's review interval. Not an edit: nothing about the page's content, last-updated time or history changes. A page with no owner gets the caller as its owner when they are one of the site's owners or editors (an admin from outside the site leaves it ownerless). Offered where `page.care.can_mark_accurate` is true. Marking again stamps the check again, so a retry is harmless.

        Refusals, in order: a page outside the caller's company is 404; a page the caller can neither open nor look after is 403 `access_denied` (nothing about its state is said); a site in the Trash is 403 `site_trashed` (said only to whoever looks after the page); a draft is 422 `not_published`; anyone but the page's owner, the site's owners and editors and admins is 403 `not_allowed`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Marked as still accurate.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesStillAccurate"
              example:
                message: Marked “Working Here” as still accurate — readers see it
                  was checked today, and it's off the Going stale list for 180 days.
                page:
                  id: 1617
                  title: Working Here
                  site_id: 42
                still_accurate:
                  checked_at: '2026-10-21T09:30:00Z'
                  checked_by:
                    id: 54
                    name: Dana Whitfield
                  review_interval_days: 180
                  next_check_due_at: '2027-04-19T09:30:00Z'
                  became_owner: false
                  owner:
                    id: 54
                    name: Dana Whitfield
                trust_line:
                  owner_name: Dana Whitfield
                  label: Checked
                  at: '2026-10-21T09:30:00Z'
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`access_denied` — the caller can neither open this page nor
            look after it; `site_trashed` — the page's site is in the Trash (restore
            the site first); `not_allowed` — only the page's owner, the site's owners
            and editors, and admins can mark it; or the token lacks `write:sites`."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: Only this page's owner and the site's owners and editors
                    can mark it as still accurate.
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '422':
          description: "`not_published` — only a published page can be marked as still
            accurate."
          content:
            application/json:
              example:
                error:
                  code: not_published
                  message: Only a published page can be marked as still accurate.
  "/sites/care_queues":
    get:
      tags:
      - Sites
      summary: Pages that need care — every list with its count
      description: 'One entry per list, in this order: `care` (every page with any
        problem), `owner` (needs an owner), `sources` (a linked policy, form or person
        changed), `links` (broken links), `stale` (going stale), `expiring` (expiring
        in 30 days) and `reading` (required reading behind). A list with pages carries
        the Dashboard row''s `detail` line. Someone who looks after no page gets `looks_after_pages:
        false` and every count 0.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The lists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  care_queues:
                    type: object
                    properties:
                      looks_after_pages:
                        type: boolean
                      scope:
                        type: string
                        enum:
                        - all_sites
                        - sites_and_pages_you_look_after
                        description: A Sites admin's lists cover every site.
                      queues:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SitesCareQueue"
              example:
                care_queues:
                  looks_after_pages: true
                  scope: sites_and_pages_you_look_after
                  queues:
                  - key: care
                    title: Pages that need care
                    count: 3
                  - key: owner
                    title: Needs an owner
                    cta_label: Choose owners
                    count: 0
                  - key: sources
                    title: Linked policy, form or person changed
                    cta_label: Review updates
                    count: 1
                    detail: Team Directory
                  - key: links
                    title: Broken links
                    cta_label: Fix links
                    count: 0
                  - key: stale
                    title: Going stale
                    cta_label: Review pages
                    count: 1
                    detail: 'Not published or checked in 180 days · oldest: How We
                      Work, published about 1 year ago'
                  - key: expiring
                    title: Expiring in 30 days
                    cta_label: Extend or archive
                    count: 1
                    detail: 'Soonest: News & Updates, on Oct 27'
                  - key: reading
                    title: Required reading behind
                    cta_label: Send reminders
                    count: 0
        '403':
          description: "`app_disabled` / `app_access_denied` — no Sites app; `insufficient_permissions`
            — the token lacks `read:sites`."
  "/sites/care_queues/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The list's key.
      schema:
        type: string
        enum:
        - care
        - owner
        - sources
        - links
        - stale
        - expiring
        - reading
    get:
      tags:
      - Sites
      summary: One "Pages that need care" list
      description: 'The list''s pages in the web queue''s order (`stale` oldest first,
        `expiring` soonest first, the rest by title), each with why it is here and
        the fixes the caller may take (`actions`). A fix is offered only where its
        door would take it, and a write only to a token holding `write:sites`: `mark_accurate`
        on every Going stale row; `extend_expiry`, `remind` and `review_refresh` to
        the site''s owners and editors and admins, `review_refresh` only while the
        company''s self-maintaining content is on or an update the editor asked for
        is waiting. `site_id` narrows the list to one site the caller may open (one
        they may not narrows it to nothing).'
      security:
      - BearerAuth: []
      parameters:
      - name: site_id
        in: query
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The list's rows.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SitesCareRow"
                    meta:
                      allOf:
                      - type: object
                        properties:
                          total_count:
                            type: integer
                          total_pages:
                            type: integer
                          current_page:
                            type: integer
                          per_page:
                            type: integer
                          has_next_page:
                            type: boolean
                          has_prev_page:
                            type: boolean
                      - type: object
                        properties:
                          queue:
                            "$ref": "#/components/schemas/SitesCareQueue"
                          scope:
                            type: string
                            enum:
                            - all_sites
                            - sites_and_pages_you_look_after
                          site_id:
                            type: integer
                            nullable: true
              example:
                items:
                - page:
                    id: 812
                    title: News & Updates
                    display_title: News & Updates
                    slug: news-updates
                    page_type: standard
                    status: published
                    published_at: '2026-04-02T10:00:00Z'
                    site:
                      id: 42
                      name: Customer Success
                      display_name: Customer Success
                    workflow_status:
                  site:
                    id: 42
                    name: Customer Success
                    display_name: Customer Success
                  kind: expiring
                  kinds:
                  - expiring
                  detail: Expires in 5 days
                  expires_at: '2026-10-27T17:00:00Z'
                  last_reviewed_at:
                  freshness_at: '2026-04-02T10:00:00Z'
                  confirm_by:
                  owner:
                    id: 7
                    name: Dana Whitfield
                    active: true
                  actions:
                    mark_accurate: false
                    extend_expiry: true
                    remind: false
                    review_refresh: false
                meta:
                  total_count: 1
                  total_pages: 1
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                  queue:
                    key: expiring
                    title: Expiring in 30 days
                    cta_label: Extend or archive
                  scope: sites_and_pages_you_look_after
                  site_id:
                total_count: 1
        '404':
          description: "`queue_not_found` — not one of the list keys."
  "/sites/pages/{page_id}/expiry_extension":
    parameters:
    - "$ref": "#/components/parameters/SitesCarePageId"
    post:
      tags:
      - Sites
      summary: Extend a page's expiry
      description: |-
        "Extend" on the Expiring in 30 days list. Moves the page's expiry date `days` (default 90; a whole number from 1 to 365) past the later of its current date and now, so a lapsed page comes back. The web moves the same date in Page settings. The expiry warning is sent again before the new date. `still_expiring: false` means the row leaves the list. Each successful call extends again — send an `Idempotency-Key` so a retry does not.

        Refusals, in order: 404 `not_found` (also a page the caller cannot know exists); 409 `transfer_pending` (to the site's owners and editors); 403 `access_denied` / `not_allowed` (only the site's owners and editors and admins); 403 `site_trashed`; 422 `invalid_days`; 422 `no_expiry` (the page has no date to extend — set one in its Page settings); 422 `not_saved`.
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                days:
                  type: integer
                  minimum: 1
                  maximum: 365
                  default: 90
            example:
              days: 90
      responses:
        '200':
          description: Extended.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: The confirmation
                    in the caller's language.:
                  page:
                    "$ref": "#/components/schemas/SitesCarePageRef"
                  expiry:
                    type: object
                    properties:
                      previous_expires_at:
                        type: string
                        format: date-time
                      expires_at:
                        type: string
                        format: date-time
                      extended_by_days:
                        type: integer
                  still_expiring:
                    type: boolean
              example:
                message: Extended “News & Updates” by 90 days.
                page:
                  id: 812
                  title: News & Updates
                  site_id: 42
                expiry:
                  previous_expires_at: '2026-10-27T17:00:00Z'
                  expires_at: '2027-01-25T17:00:00Z'
                  extended_by_days: 90
                still_expiring: false
        '403':
          "$ref": "#/components/responses/SitesCareRefused"
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '409':
          "$ref": "#/components/responses/SitesCareTransferPending"
        '422':
          description: "`invalid_days` (not a whole number from 1 to 365), `no_expiry`
            or `not_saved` (the reason is in `message`)."
  "/sites/pages/{page_id}/read_confirmation_reminders":
    parameters:
    - "$ref": "#/components/parameters/SitesCarePageId"
    post:
      tags:
      - Sites
      summary: Remind the readers who have not confirmed a must-read page
      description: |-
        "Send reminders" on the Required reading behind list — the web roster's "Remind the rest". Reminds, in the app, everyone the live version asked who has not confirmed, is still an active member and can still open the page — except anyone reminded in the last `interval_hours`, and never the sender. `status`: `queued` (reminders are on their way to `reminding` people; `skipped` were reminded recently), `nobody` (everyone who can still open it confirmed), `all_recent` (everyone due was reminded recently), `sample` (sample pages never send), `withheld` (the group is too small to count — no numbers are given, and whoever owes it is still reminded). The page stays on the list until its readers confirm.

        Refusals: 404 `not_found` (also a page the caller cannot know exists); 409 `transfer_pending` (to the site's owners and editors); 403 `access_denied` / `not_allowed` (only the site's owners and editors and admins); 422 `not_asking` (the page does not ask readers to confirm).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Sent, or why nobody was reminded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  page:
                    "$ref": "#/components/schemas/SitesCarePageRef"
                  reminder:
                    type: object
                    properties:
                      status:
                        type: string
                        enum:
                        - queued
                        - nobody
                        - all_recent
                        - sample
                        - withheld
                      reminding:
                        type: integer
                        nullable: true
                      skipped:
                        type: integer
                        nullable: true
                      interval_hours:
                        type: integer
              example:
                message: Reminding 4 people who haven't confirmed yet.
                page:
                  id: 640
                  title: Fire evacuation
                  site_id: 42
                reminder:
                  status: queued
                  reminding: 4
                  skipped: 0
                  interval_hours: 12
        '403':
          "$ref": "#/components/responses/SitesCareRefused"
        '404':
          description: "`not_found`."
        '409':
          "$ref": "#/components/responses/SitesCareTransferPending"
        '422':
          description: "`not_asking` — this page isn't asking readers to confirm they've
            read it."
  "/sites/pages/{page_id}/source_refresh":
    parameters:
    - "$ref": "#/components/parameters/SitesCarePageId"
    get:
      tags:
      - Sites
      summary: Review a page's source refresh
      description: |-
        "Review updates" on the Linked policy, form or person changed list — the web's Review refresh screen. `kind` is `source` (a policy, form or person a section is bound to changed; `drift` lists them and the proposal removes the records that are gone) or `import_update` (the web page it was imported from changed and an AI-prepared update waits; `changes` lists what changed). Read-only: a proposal not yet saved (`staged: false`) is computed for display, never written. `can_publish` / `publish_refusal` answer for the page as it stands now; the gate is asked again when it publishes. `can_apply` and `can_publish` are false for a token without `write:sites`, which apply asks.

        Refusals: 404 `not_found` (also a page the caller cannot know exists); 409 `transfer_pending` (to the site's owners and editors); 403 `access_denied` / `not_allowed`; 403 `content_sync_disabled` (the company's self-maintaining content is off and no update the editor asked for is waiting).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The proposal.
          content:
            application/json:
              schema:
                type: object
                properties:
                  source_refresh:
                    "$ref": "#/components/schemas/SitesSourceRefresh"
              example:
                source_refresh:
                  page:
                    id: 905
                    title: Team Directory
                    site_id: 42
                    status: published
                  kind: source
                  staged: true
                  has_content_change: true
                  changes: []
                  drift:
                  - section_id: s3
                    section_type: people
                    section_name: People
                    heading: Our team
                    issue: removed
                    source_label: People
                    missing_records: 1
                  proposed_sections:
                  - position: 1
                    type: people
                    type_name: People
                    heading: Our team
                    excerpt:
                    differs_from_draft: true
                  proposal_digest: 9f2c…e1
                  generated_at: '2026-10-21T02:10:00Z'
                  can_apply: true
                  can_publish: true
                  publish_refusal:
        '403':
          "$ref": "#/components/responses/SitesCareRefused"
        '404':
          description: "`not_found`."
        '409':
          "$ref": "#/components/responses/SitesCareTransferPending"
    delete:
      tags:
      - Sites
      summary: Dismiss a page's source refresh
      description: 'Keeps the page as it is. An import update adopts the source''s
        new version, so the same change is not offered again; a source refresh clears
        the proposal and dismisses its open findings. `findings_cleared: false` —
        the proposal was dismissed, but its findings could not be cleared now; they
        are checked again on the next sync.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dismissed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  page:
                    "$ref": "#/components/schemas/SitesCarePageRef"
                  findings_cleared:
                    type: boolean
              example:
                message: Refresh dismissed. The page was left unchanged.
                page:
                  id: 905
                  title: Team Directory
                  site_id: 42
                findings_cleared: true
        '403':
          "$ref": "#/components/responses/SitesCareRefused"
        '404':
          description: "`not_found`."
        '409':
          "$ref": "#/components/responses/SitesCareTransferPending"
        '500':
          description: "`failed` — the update could not be dismissed; nothing changed."
  "/sites/pages/{page_id}/source_refresh/apply":
    parameters:
    - "$ref": "#/components/parameters/SitesCarePageId"
    post:
      tags:
      - Sites
      summary: Apply a page's source refresh, optionally publishing it
      description: |-
        Writes the proposal into the page's DRAFT — the live page changes only when it is published. A source proposal not saved yet is prepared first (what the review showed). With `publish: true` the draft is then published through the same gate as the page's Publish button: a page that needs approval stays applied in its draft, with `published: false` and the reason in `publish_refusal`. Without `publish`, `published` is null. A publish that fails after the draft was written still answers 200, with `published: false` and the reason. Retries with the same `Idempotency-Key` replay the first answer.

        Refusals: 404 `not_found` (also a page the caller cannot know exists); 409 `transfer_pending` (to the site's owners and editors); 403 `access_denied` / `not_allowed` / `content_sync_disabled`; 422 `not_published` (a source refresh needs a published page); 422 `nothing_to_apply`; 409 `stale` (the page changed after the proposal was prepared — nothing was overwritten).
      security:
      - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                publish:
                  type: boolean
                  default: false
                proposal_digest:
                  type: string
                  description: The review's `proposal_digest`. When sent, a proposal
                    that changed since the review is refused 409 `stale` instead of
                    being applied.
            example:
              publish: true
              proposal_digest: 9f2c…e1
      responses:
        '200':
          description: Applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  page:
                    allOf:
                    - "$ref": "#/components/schemas/SitesCarePageRef"
                    - type: object
                      properties:
                        status:
                          type: string
                  published:
                    type: boolean
                    nullable: true
                  publish_refusal:
                    type: string
                    nullable: true
              example:
                message: Update applied and published.
                page:
                  id: 905
                  title: Team Directory
                  site_id: 42
                  status: published
                published: true
                publish_refusal:
        '403':
          "$ref": "#/components/responses/SitesCareRefused"
        '404':
          description: "`not_found`."
        '409':
          description: "`stale` — the page changed after this refresh was prepared,
            or the proposal no longer matches the `proposal_digest` sent; nothing
            was overwritten — review it again. `transfer_pending` — the page or its
            site is still being imported."
        '500':
          description: "`failed` — the refresh could not be applied; the page draft
            was left unchanged."
        '422':
          description: "`not_published` or `nothing_to_apply` (the reason is in `message`)."
  "/sites/pages/{id}/archive":
    parameters:
    - name: id
      in: path
      required: true
      description: The page's id.
      schema:
        type: integer
    post:
      tags:
      - Sites
      summary: Archive a page
      description: |-
        The web's "Archive page" (the page's ⋮ menu, the editor's page menu, the Pages list's bulk bar). The page stops being visible to readers but stays on the site for its editors, who can publish it again; a pending approval is ended. The page's author is told who archived it. Offered where `page.viewer.can_archive` is true: the site's owners and editors and company admins / Sites app-admins, on a page not already archived.

        Refusals, in order: a page outside the caller's company is 404; a page the caller can neither open nor archive is 403 `access_denied` (nothing about it is said); a page or site still being imported is 409 `transfer_pending`; anyone but the site's owners and editors and admins is 403 `not_allowed`; an archived page is 422 `already_archived` (and its author is not told twice); an archive the page itself refuses is 422 `not_removed`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Archived.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesPageArchived"
              example:
                message: Page archived
                page:
                  id: 1617
                  title: Holiday rota
                  site_id: 42
                  status: archived
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`access_denied` — the caller can neither open this page nor
            archive it; `not_allowed` — only the site's owners and editors and admins
            can archive its pages; or the token lacks `destructive:sites`."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: You need editor access to modify pages in this site.
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '409':
          description: "`transfer_pending` — the page or its site is still being imported."
        '422':
          description: "`already_archived` — the page is already archived; `not_removed`
            — the page could not be archived (the reason is in `message`)."
          content:
            application/json:
              example:
                error:
                  code: already_archived
                  message: This page is already archived.
  "/sites/sites/{id}/archive":
    parameters:
    - "$ref": "#/components/parameters/SitesSiteId"
    post:
      tags:
      - Sites
      summary: Archive a site
      description: |-
        The web's "Archive" (the site's ⋮ menu). The site leaves All sites and its pages stop being available to readers; the site, its pages, members and followers are kept, and an owner or editor can publish it again. Its members and followers are told who archived it. Offered where the site's `can_archive` is true: the site's owners and editors and company admins / Sites app-admins, on a site not already archived.

        Refusals, in order: a site outside the caller's company is 404; a site the caller can neither open nor archive is 404 `not_found` too (nothing about it is said); a site still being imported is 409 `transfer_pending`; anyone but the site's owners and editors and admins is 403 `not_allowed`; a site in Trash is 409 `in_trash` (restore it first); an archived site is 422 `already_archived` (and nobody is told twice); an archive the site itself refuses is 422 `not_removed`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Archived.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesSiteArchived"
              example:
                message: Site archived
                site:
                  id: 42
                  name: Benefits 2025
                  status: archived
                  archived_at: '2026-10-22T09:30:00Z'
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`not_allowed` — only the site's owners and editors and admins
            can archive it; `insufficient_permissions` — the token lacks `destructive:sites`."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: You need editor access to modify this site.
        '404':
          description: "`not_found` — no site with that id in the caller's company,
            or one the caller can neither open nor archive."
        '409':
          description: "`transfer_pending` — the site is still being imported; `in_trash`
            — the site is in Trash: restore it from All sites › ⋮ › Trash first."
          content:
            application/json:
              example:
                error:
                  code: in_trash
                  message: '"Benefits 2025" is in Trash. Restore it from All sites
                    › ⋮ › Trash first.'
        '422':
          description: "`already_archived` — the site is already archived; `not_removed`
            — the site could not be archived (the reason is in `message`)."
          content:
            application/json:
              example:
                error:
                  code: already_archived
                  message: This site is already archived.
  "/sites/sites/{id}/trash":
    parameters:
    - "$ref": "#/components/parameters/SitesSiteId"
    post:
      tags:
      - Sites
      summary: Move a site to Trash
      description: |-
        The web's "Move to Trash" (the site's ⋮ menu). Readers and members lose access to the site and every page in it at once. Nothing is deleted: its pages, members, followers and bookmarks stay, and the site comes back — as a draft — when restored from All sites › ⋮ › Trash on the web, the only place a site can be deleted permanently. `status` keeps the value it had (Trash is not a status). Its members and followers are told who moved it; its owners, that they can restore it. Offered where the site's `can_trash` is true: the site's owners and company admins / Sites app-admins.

        Refusals, in order: a site outside the caller's company is 404; a site the caller can neither open nor move to Trash is 404 `not_found` too — which includes a site already in Trash, for anyone but an admin; a site still being imported is 409 `transfer_pending`; anyone but the site's owners and admins is 403 `not_allowed`; a site already in Trash is 422 `already_trashed` (admins); a move the site itself refuses is 422 `not_removed`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Moved to Trash.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesSiteTrashed"
              example:
                message: '"Benefits 2025" moved to Trash. You can restore it from
                  All sites › ⋮ › Trash.'
                site:
                  id: 42
                  name: Benefits 2025
                  status: published
                  trashed_at: '2026-10-22T09:30:00Z'
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`not_allowed` — only the site's owners and admins can move
            it to Trash; `insufficient_permissions` — the token lacks `destructive:sites`."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: Only site owners can perform this action.
        '404':
          description: "`not_found` — no site with that id in the caller's company,
            or one the caller can neither open nor move to Trash (a site already in
            Trash, for anyone but an admin)."
        '409':
          description: "`transfer_pending` — the site is still being imported."
        '422':
          description: "`already_trashed` — the site is already in Trash; `not_removed`
            — the site could not be moved (the reason is in `message`)."
          content:
            application/json:
              example:
                error:
                  code: already_trashed
                  message: '"Benefits 2025" is already in Trash.'
  "/sites/sites/{id}/publish":
    parameters:
    - name: id
      in: path
      required: true
      description: The site's id.
      schema:
        type: integer
    post:
      tags:
      - Sites
      summary: Publish a site
      description: |-
        The web's "Publish site" (a draft site's button under Follow, and the site's Manage ▾ › Publish). The site opens at once to the people its audience admits — everyone in the company when it is for everyone, only the people its audience rules name when it is limited, and only its members and admins while it has no audience yet. Its draft pages stay drafts. To take it down later, archive it (`POST /sites/sites/{id}/archive`). An archived site, or one restored from Trash, published again tells its members and followers it is back. Offered where the site's `can_publish` is true; the draft prompt is `is_draft && can_publish`.

        Refusals, in order: a site outside the caller's company is 404; a site the caller can neither open nor publish is 404 `not_found` too (nothing about it is said); a site still being imported is 409 `transfer_pending`; anyone but the site's owners and editors and admins is 403 `not_allowed`; a site in Trash is 409 `in_trash` (restore it first); a published site is 422 `already_published`; a site whose imported audience a Sites admin has not yet chosen in Export & Import is 409 `audience_held`; an archived site published again while the company is at its site limit (Sites settings › Limits › Max sites) is 422 `over_site_limit` — an archived site holds no place against the limit; a publish the site itself refuses is 422 `not_published`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Published.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesSitePublished"
              example:
                message: Site published
                site:
                  id: 42
                  name: Q3 Launch — Lighthouse
                  status: published
                  is_draft: false
                  published_at: '2026-10-22T09:30:00Z'
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`not_allowed` — only the site's owners and editors and admins
            can publish it; `insufficient_permissions` — the token lacks `write:sites`."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: You need editor access to modify this site.
        '404':
          description: "`not_found` — no site with that id in the caller's company,
            or one the caller can neither open nor publish."
        '409':
          description: "`transfer_pending` — the site is still being imported; `in_trash`
            — the site is in Trash: restore it from All sites › ⋮ › Trash first; `audience_held`
            — a Sites admin must choose the site's imported audience in Export & Import
            before it can be published."
          content:
            application/json:
              example:
                error:
                  code: in_trash
                  message: '"Q3 Launch — Lighthouse" is in Trash. Restore it from
                    All sites › ⋮ › Trash first.'
        '422':
          description: "`already_published` — the site is already published; `over_site_limit`
            — the site is archived and the company is at its site limit: archive another
            site or raise the limit first; `not_published` — the site could not be
            published (the reason is in `message`)."
          content:
            application/json:
              example:
                error:
                  code: already_published
                  message: This site is already published.
  "/sites/sites":
    get:
      tags:
      - Sites
      summary: List the sites the caller can open
      security:
      - BearerAuth: []
      parameters:
      - name: type
        in: query
        required: false
        description: A site category of this tenant (`site_type`). An unknown value
          is 422 `invalid_type`.
        schema:
          type: string
      - name: status
        in: query
        required: false
        description: 'A site status. Without it the list is the one All sites shows:
          live sites, never an archived one, and a draft only to its members (and
          Sites admins). `status=archived` lists the archived sites the caller may
          open. An unknown value is 422 `invalid_status`.'
        schema:
          type: string
      - name: top_level
        in: query
        required: false
        description: "`true` — only sites that sit under no other site (All sites'
          list)."
        schema:
          type: boolean
      - name: parent_site_id
        in: query
        required: false
        description: Only the sites directly under this site.
        schema:
          type: integer
      - name: yours
        in: query
        required: false
        description: '`true` — "Your sites": the ones the caller is a member of (any
          role), follows, or that are For them. Never an archived site.'
        schema:
          type: boolean
      - name: grouped
        in: query
        required: false
        description: "`true` — the rows in All sites' order: by group, A to Z inside
          each (instead of most recently updated first), with `meta.groups` counting
          every group over the whole filtered set, so each section can be headed with
          its count on any page. Use with `top_level=true` for the All sites screen.
          Combines with the other filters."
        schema:
          type: boolean
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 20
          maximum: 100
      responses:
        '200':
          description: Sites, most recently updated first — or, with `grouped=true`,
            by group then A to Z
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SitesSite"
                    meta:
                      allOf:
                      - type: object
                        properties:
                          total_count:
                            type: integer
                          total_pages:
                            type: integer
                          current_page:
                            type: integer
                          per_page:
                            type: integer
                          has_next_page:
                            type: boolean
                          has_prev_page:
                            type: boolean
                      - type: object
                        properties:
                          groups:
                            type: array
                            description: 'Only with `grouped=true`: every group in
                              display order, counted over the whole filtered set (not
                              only this page). Empty groups are never listed.'
                            items:
                              "$ref": "#/components/schemas/SitesDirectoryGroup"
              example:
                items:
                - id: 12
                  name: Customer Success
                  display_name: Customer Success
                  site_type: department
                  status: published
                  pages_count: 14
                  member_role: owner
                  is_published: true
                  is_draft: false
                  following: false
                  group:
                    key: departments
                    title: Departments
                  chip: Member
                  sites_inside: 2
                  last_updated_at: '2026-10-16T14:05:00Z'
                  can_edit: true
                  can_archive: true
                  can_trash: true
                  can_publish: false
                  restricted: false
                meta:
                  total_count: 9
                  total_pages: 1
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                  groups:
                  - key: departments
                    title: Departments
                    count: 3
                  - key: category-company
                    title: Company-wide
                    count: 2
                  - key: category-knowledge_base
                    title: Knowledge base
                    count: 2
                  - key: category-team
                    title: Team
                    count: 2
                total_count: 9
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: The Sites app is not enabled or not accessible to the caller.
        '422':
          description: "`invalid_type` or `invalid_status`."
  "/sites/sites/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: Read one site
      description: The site with its parent (when the caller may open it), the sites
        under it, who it is For, and its pages — at most 100, audience- and status-filtered
        for the caller; `pages_truncated` says when there are more (page through `GET
        /sites/pages?site_id=`).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The site
          content:
            application/json:
              schema:
                type: object
                properties:
                  site:
                    allOf:
                    - "$ref": "#/components/schemas/SitesSite"
                    - type: object
                      properties:
                        parent_site:
                          type: object
                          nullable: true
                          description: The site the back button names — the parent,
                            when the caller may open it. Null for a top-level site,
                            or a parent the caller cannot open (`parent_site_id` still
                            says which it is).
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            display_name:
                              type: string
                              description: The name in the caller's language; the
                                original when there is no translation.
                        child_sites:
                          type: array
                          maxItems: 100
                          description: The published sites directly under this one
                            that the caller may open, by name (a Sites app admin sees
                            every one not archived, drafts included) — what each "Sites
                            in <name>" card says.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                              display_name:
                                type: string
                                description: The name in the caller's language; the
                                  original when there is no translation.
                              status:
                                type: string
                                description: The site's status — a card badges anything
                                  but `published` (a Sites admin sees drafts).
                              sites_inside:
                                type: integer
                                description: The sites beneath it at any depth that
                                  the caller may open.
                              owner:
                                type: object
                                nullable: true
                                description: Its first active owner (who keeps it);
                                  null when it has none.
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                              last_updated_at:
                                type: string
                                format: date-time
                                description: When a reader last saw it change — the
                                  later of the site row and its newest page change
                                  the caller can read.
                        owners:
                          type: array
                          maxItems: 4
                          description: The site's active owners, first four by when
                            they joined (the Site info card); `owners_count` says
                            how many there are in all.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                        owners_count:
                          type: integer
                          description: Every active owner of the site ("+N more" past
                            `owners`).
                        followers_count:
                          type: integer
                          description: How many people follow the site.
                        for_description:
                          type: string
                          description: Who the site is For, in words — present only
                            when `for_criteria` names anyone.
                          example: Bakery (and departments under it)
                        pages_truncated:
                          type: boolean
                        pages:
                          type: array
                          maxItems: 100
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              title:
                                type: string
                              slug:
                                type: string
                              page_type:
                                type: string
                              status:
                                type: string
                              position:
                                type: integer
                                nullable: true
                              published_at:
                                type: string
                                format: date-time
                                nullable: true
                              updated_at:
                                type: string
                                format: date-time
                              workflow_status:
                                "$ref": "#/components/schemas/SitesPageWorkflowStatus"
        '404':
          description: "`not_found` — no such site, or one the caller cannot open
            (both answer alike, so ids cannot be walked to map hidden sites)."
  "/sites/sites/{site_id}/members":
    parameters:
    - name: site_id
      in: path
      required: true
      description: The site's id.
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: List a site's members (Owners, Editors, Readers)
      description: |-
        The web's Members page, in its order: owners, then editors, then viewers. It is paged like the web (50 a page by default, `per_page` up to 100). Each page's rows are dealt into three sections: `owners`, `editors` and `readers` (viewer memberships). Every section's `count` covers the whole site, so a client can head each section correctly on any page. A later page's `items` are appended to the section they belong to.

        The `readers` section also carries the site's `audience`: who can read the site without a membership. Its `rules` name people, departments and groups, so they are given only to someone who manages the site's members (its owners and admins), as the web's Audience card is. `added_by` (who granted the membership) follows the same rule.

        `can_remove` at the top level says whether the caller may remove members (a site owner or a Sites admin, with a token holding `destructive:sites`). On a row, it says whether the caller can remove that member: for a manager, every member but the site's last owner; for anyone else, their own row (leaving the site), unless they are its last owner.

        Section `title`s, `role_label` and the audience's words come in the language the client asks for (`locale`, else `Accept-Language`, else the person's own language).

        Refusals: a site outside the caller's company, or one the caller may not open, is 404 `not_found`, as for an unknown id.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: The page of the roster, in sections.
          headers:
            X-Total-Count:
              schema:
                type: integer
              description: Members on the site.
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesMemberRoster"
              example:
                site:
                  id: 42
                  name: Customer Success
                  display_name: Customer Success
                can_remove: true
                sections:
                - key: owners
                  title: Owners
                  role: owner
                  count: 1
                  items:
                  - id: 311
                    role: owner
                    role_label: Owner
                    user:
                      id: 7
                      name: Olga Owner
                      avatar_url: https://officechat.workforce.mangoapps.com/rails/active_storage/representations/…
                    is_current_user: true
                    can_remove: false
                    added_by:
                      id: 2
                      name: Nina Admin
                - key: editors
                  title: Editors
                  role: editor
                  count: 2
                  items:
                  - id: 312
                    role: editor
                    role_label: Editor
                    user:
                      id: 9
                      name: Dana Editor
                      avatar_url: https://ui-avatars.com/api/?name=Dana+Editor
                    is_current_user: false
                    can_remove: true
                    added_by:
                      id: 7
                      name: Olga Owner
                - key: readers
                  title: Readers
                  role: viewer
                  count: 0
                  items: []
                  audience:
                    restricted: true
                    title: Only specific people
                    text: Only this audience, the site's members and admins can open
                      the site.
                    rules:
                    - 'Department: Finance'
                    - 'Location: Downtown'
                total_count: 3
                meta:
                  total_count: 3
                  total_pages: 1
                  current_page: 1
                  per_page: 50
                  has_next_page: false
                  has_prev_page: false
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`insufficient_permissions` — the token lacks `read:sites`;
            `app_disabled` — Sites is not enabled for the company; `app_access_denied`
            — the caller has no access to the Sites app."
        '404':
          description: "`not_found` — no site with that id in the caller's company,
            or one the caller may not open."
  "/sites/sites/{site_id}/members/{user_id}":
    parameters:
    - name: site_id
      in: path
      required: true
      description: The site's id.
      schema:
        type: integer
    - name: user_id
      in: path
      required: true
      description: The member's user id (`items[].user.id` in the roster).
      schema:
        type: integer
    delete:
      tags:
      - Sites
      summary: Remove a member from a site
      description: |-
        The web's Remove on the Members page. The person loses their role on the site. Any pages they owned on it pass to the site's first remaining active owner, else editor; with no such person, those pages are left without an owner. The person is told. They can still read the site if it is published and its audience admits them. Offered where the row's `can_remove` is true: the site's owners and company admins / Sites app-admins can remove anyone except the site's last owner, and any member can remove themselves (leave the site) unless they are its last owner. Removing yourself answers `left_site: true`.

        Refusals, in order: a site outside the caller's company is 404 `not_found`, and so is a site the caller can neither open nor manage; a site still being imported is 409 `transfer_pending` (admins included); anyone but the site's owners and admins, removing someone other than themselves, is 403 `not_allowed` (before the person is looked up); an unknown person is 404 `user_not_found`; a person who is not a member is 404 `not_a_member`; the site's last owner is 409 `last_owner`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Removed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesMemberRemoved"
              example:
                message: Member removed. The 1 page they owned here now belongs to
                  Olga Owner.
                removed:
                  user:
                    id: 9
                    name: Dana Editor
                  role: editor
                left_site: false
                pages_handed_on:
                  count: 1
                  new_owner:
                    id: 7
                    name: Olga Owner
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`not_allowed` — only the site's owners and admins can remove
            other members; `insufficient_permissions` — the token lacks `destructive:sites`;
            `app_disabled` — Sites is not enabled for the company; `app_access_denied`
            — the caller has no access to the Sites app."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: Only site owners can perform this action.
        '404':
          description: "`not_found` — no site with that id in the caller's company,
            or one the caller can neither open nor manage; `user_not_found` — no such
            person in the company; `not_a_member` — the person has no role on this
            site."
        '409':
          description: "`last_owner` — a site must keep at least one owner; `transfer_pending`
            — the site is still being imported."
          content:
            application/json:
              example:
                error:
                  code: last_owner
                  message: A site must keep at least one owner.
  "/sites/pages":
    get:
      tags:
      - Sites
      summary: List the pages the caller can read
      description: Pages of the sites the caller can open, audience-filtered for the
        caller; drafts and archived pages only where the caller may see them (their
        own, a review assigned to them, or — with `site_id` — a site they edit). Ordered
        as the site orders them.
      security:
      - BearerAuth: []
      parameters:
      - name: site_id
        in: query
        required: false
        description: Only this site's pages. A site the caller cannot open is 404
          `not_found`.
        schema:
          type: integer
      - name: status
        in: query
        required: false
        description: A page status. An unknown value is 422 `invalid_status`.
        schema:
          type: string
          enum:
          - draft
          - published
          - archived
      - name: page_type
        in: query
        required: false
        description: A page type. An unknown value is 422 `invalid_page_type`.
        schema:
          type: string
          enum:
          - content
          - news
          - wiki
          - announcement
          - landing
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 20
          maximum: 100
      responses:
        '200':
          description: Pages
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SitesPage"
                    meta:
                      type: object
                      properties:
                        total_count:
                          type: integer
                        total_pages:
                          type: integer
                        current_page:
                          type: integer
                        per_page:
                          type: integer
                        has_next_page:
                          type: boolean
                        has_prev_page:
                          type: boolean
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: The Sites app is not enabled or not accessible to the caller.
        '404':
          description: "`not_found` — `site_id` names a site the caller cannot open."
        '422':
          description: "`invalid_status` or `invalid_page_type`."
  "/sites/pages/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Sites
      summary: Read one page
      description: 'The page with its body. A reader gets the published body cut to
        the sections their audience may read, in their language when a fresh, fully
        translated version exists (`?locale=` asks for one; `original` for the original).
        An editor of the site gets the unpublished DRAFT body, in the original language,
        and `ai_assisted` then describes that draft. Opening a page here counts as
        a read (Page Analytics, Recently read) — except an editor''s look at a draft.
        Around the body it carries what the page''s own screen shows (`SitesPageDetail`):
        reactions, the live version, "updated since you last read", the Approval panel,
        Going stale and the rest, each for the people the web page shows it to.'
      security:
      - BearerAuth: []
      parameters:
      - name: locale
        in: query
        required: false
        description: A translation's language code, or `original`.
        schema:
          type: string
      responses:
        '200':
          description: The page
          content:
            application/json:
              schema:
                type: object
                properties:
                  page:
                    allOf:
                    - "$ref": "#/components/schemas/SitesPage"
                    - type: object
                      properties:
                        sections:
                          type: array
                          description: The body the caller may read — the translation's
                            when one is served, the draft for an editor.
                          items:
                            type: object
                            additionalProperties: true
                        content_text:
                          type: string
                          nullable: true
                          description: The same body as plain text.
                        audience_description:
                          type: string
                          description: Who the page is for, in words — editors only.
                        locale:
                          type: string
                          description: The language served — `original` for the original.
                        locale_auto_selected:
                          type: boolean
                          description: True when the caller's own language was picked
                            for them rather than asked for.
                        available_locales:
                          type: array
                          items:
                            type: object
                            properties:
                              code:
                                type: string
                              name:
                                type: string
                              status:
                                type: string
                              verified:
                                type: boolean
                              stale:
                                type: boolean
                              sections_translated:
                                type: boolean
                        read_confirmation:
                          type: object
                          description: 'Whether the live version of a page asks THIS
                            caller to confirm reading it. `{ requested: false }` when
                            it does not.'
                          properties:
                            requested:
                              type: boolean
                            acknowledged:
                              type: boolean
                            acknowledged_at:
                              type: string
                              format: date-time
                              nullable: true
                            version_number:
                              type: integer
                            due_on:
                              type: string
                              format: date
                              nullable: true
                            overdue:
                              type: boolean
                    - "$ref": "#/components/schemas/SitesPageDetail"
        '403':
          description: 'Only to a caller who can already see the page exists — an
            editor of the site, a reader of a page that was once live (archived, expired,
            unpublished), or the page''s assigned reviewer outside its audience: `access_denied`
            or `not_published`, with the page''s own reason.'
        '404':
          description: "`not_found` — the id does not exist, OR the caller may not
            know the page exists (it is on a site they cannot open, or never published
            to them). Both answer identically, so ids cannot be walked to map hidden
            pages."
    delete:
      tags:
      - Sites
      summary: Move a page to Trash
      description: |-
        The web's "Move to Trash" (the page's ⋮ menu, the editor's page menu, the Pages list's bulk bar). The page leaves the site and goes to the site's Manage › Pages › Trash, where its owners and editors can restore it as a draft for 90 days (`trash.restorable_until`). Comments, translations, view history, watchers, bookmarks and past versions are deleted for good, apart from any version that asked readers to confirm they had read the page (`trash.asked_to_confirm`), which comes back with it. The page's author is told who moved it. Offered where `page.viewer.can_delete` is true: the site's owners and company admins / Sites app-admins.

        Refusals, in order: a page outside the caller's company (or already in Trash) is 404; a page the caller can neither open nor move to Trash is 403 `access_denied` (nothing about it is said) — or 404 `not_found` when the caller cannot know the page exists; a page or site still being imported is 409 `transfer_pending`; anyone but the site's owners and admins is 403 `not_allowed`; a delete the page itself refuses is 422 `not_removed`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Moved to Trash.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: The web's confirmation, in the caller's language.
                  page:
                    "$ref": "#/components/schemas/SitesRemovedPage"
                  trash:
                    type: object
                    properties:
                      restorable_until:
                        type: string
                        format: date-time
                        description: Until when the site's owners and editors can
                          restore it (as a draft) from the site's Trash.
                      restore_window_days:
                        type: integer
                        description: How long a page stays restorable in Trash.
                      asked_to_confirm:
                        type: boolean
                        description: The page had versions that asked readers to confirm
                          they had read it; those come back with it on restore, with
                          the record of who confirmed.
              example:
                message: Page moved to Trash. You can restore it from this site's
                  Manage › Pages › Trash for 90 days.
                page:
                  id: 1617
                  title: Refund policy
                  site_id: 42
                trash:
                  restorable_until: '2027-01-19T09:30:00Z'
                  restore_window_days: 90
                  asked_to_confirm: false
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`access_denied` — the caller can neither open this page nor
            move it to Trash, but can see it exists (otherwise 404 `not_found`); `not_allowed`
            — only the site's owners and admins can move its pages to Trash; or the
            token lacks `destructive:sites`."
          content:
            application/json:
              example:
                error:
                  code: not_allowed
                  message: Only site owners can move pages to Trash.
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '409':
          description: "`transfer_pending` — the page or its site is still being imported."
        '422':
          description: "`not_removed` — the page could not be moved to Trash (the
            reason is in `message`)."
  "/sites/search":
    get:
      tags:
      - Sites
      summary: Search the sites and pages the caller can read
      description: |-
        The Sites search the phone and the desktop show, as data: the sites a
        query finds (`sites`, first page only) and the pages it finds
        (`items`, paged). One query finds the same sites and pages, in the same
        order, as the phone's Sites search.

        **Sites** — the All sites search: live sites the caller may open, at
        any depth (published sites whose audience admits the caller, and sites
        they are a member of or manage, drafts included; every live site for a
        Sites admin; never an archived site). A site matches when `q` is in
        its name or description, or — with several words — when every word is
        in one or the other; a site whose name holds every word comes first,
        then by name. At most 50 are listed; `meta.sites_more` is true when more
        matched. Each is a site row of the caller's lists (its icon on its
        `tile_color`, `display_name`, `display_description`, `status` for a
        Draft badge, `restricted`) plus `pages_count` and `sites_inside` — the
        row's "3 pages · 2 sites inside", counted as GET /sites/sites counts
        them — and `parents`, where a nested site sits, root first, naming
        only sites the caller may open. On page 2 and later `sites` is empty.

        **Pages** — the platform Search's Sites results: currently published
        pages (not past their expiry) on sites the caller can open, filtered
        to the page's audience for the caller. A page matches on its title
        (closely, in any word order), or when `q` — or a quoted phrase in it,
        or one of the company's search aliases for it — appears in its body
        text or description. A term of three or fewer letters matches only at
        the start of a word. Best match first.

        Each page carries an `excerpt`: the sentence around the first match in
        the body text the caller may read (or the description), with "…" where
        it was cut, and `matched_term`, the words as the page writes them. A
        page matched by its title alone has no `matched_term`; its `excerpt`
        is its description (up to 200 characters). A page whose only match is
        in a section hidden from the caller is left out. Its `site` is the site
        as every list draws it, so the row can show the site's icon on its
        `tile_color` beside the site's `display_name`.

        Every branch answers the same shape — `sites` beside `items`, and the
        pagination keys plus `query`, `results_count` and `sites_more` in
        `meta`:
        * `q` missing, blank, or not a single value (`q[]=…`): no search runs;
          `items` and `sites` are empty, `meta.query` is `""`,
          `results_count` and `total_count` are 0.
        * Otherwise the results; `meta.query` echoes the trimmed `q` and
          `results_count` equals `total_count` (pages).

        A first-page search is recorded for the Sites knowledge-gap report,
        counted in pages.

        **Required Scopes:** `read:sites` (or `read:own_sites`). A token that
        names scopes without either is refused with 403
        `insufficient_permissions` ("Required scope: read:sites").
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: The search text. Surrounding whitespace is trimmed.
        schema:
          type: string
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Results per page; a value outside 1–100 is clamped to it.
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Matching sites (first page) and matching pages, best match
            first
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  - sites
                  properties:
                    items:
                      type: array
                      description: The matching pages, paged.
                      items:
                        "$ref": "#/components/schemas/SitesSearchResult"
                    sites:
                      type: array
                      description: The matching sites, at most 50; empty on page 2
                        and later and when no search ran.
                      items:
                        "$ref": "#/components/schemas/SitesSearchSite"
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/CollectionPaginationMeta"
                      - type: object
                        required:
                        - query
                        - results_count
                        - sites_more
                        properties:
                          query:
                            type: string
                            description: The trimmed `q`; `""` when no search ran.
                          results_count:
                            type: integer
                            description: The same number as `total_count` (pages);
                              0 when no search ran.
                          sites_more:
                            type: boolean
                            description: More sites matched than `sites` lists — type
                              more words to narrow it.
              example:
                sites:
                - id: 41
                  name: Engineering Team
                  display_name: Engineering Team
                  slug: engineering-team
                  site_type: team
                  status: published
                  icon: fa-code
                  icon_color:
                  tile_color: "#2e63b3"
                  display_description: Engineering team site — runbooks, on-call,
                    and announcements.
                  restricted: false
                  pages_count: 3
                  sites_inside: 0
                  parents: []
                items:
                - id: 812
                  title: Service Catalogue
                  slug: service-catalogue
                  page_type: content
                  description: What Engineering Team handles, what is self-serve,
                    and how fast we answer.
                  excerpt: What Engineering Team handles, what is self-serve, and
                    how fast we answer.
                  matched_term: Engineering
                  published_at: '2026-10-02T09:00:00Z'
                  updated_at: '2026-10-02T09:00:00Z'
                  site:
                    id: 41
                    name: Engineering Team
                    display_name: Engineering Team
                    slug: engineering-team
                    site_type: team
                    status: published
                    icon: fa-code
                    icon_color:
                    tile_color: "#2e63b3"
                  created_by:
                    id: 7
                    name: Dana Whitfield
                meta:
                  query: Engineering
                  results_count: 3
                  sites_more: false
                  current_page: 1
                  per_page: 20
                  total_count: 3
                  total_pages: 1
                  has_next_page: false
                  has_prev_page: false
                total_count: 3
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            * `insufficient_permissions` — the token lacks the scope ("Required scope: read:sites").
            * `app_disabled` — the Sites app is not enabled for the business.
            * `app_access_denied` — the caller does not have access to the Sites app.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Sites app is not configured on this
            installation."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/sites/bookmarks":
    get:
      tags:
      - Sites
      summary: List the caller's bookmarks
      description: |-
        The caller's own bookmarks — of sites and of pages — most recently
        bookmarked first. Only bookmarks whose site or page the caller can
        still open are listed: a bookmarked site moved to Trash, back to
        draft, or whose membership or audience the caller lost is left out,
        and so is a page the caller can no longer read (its status, its
        audience, or its site). Such a bookmark is kept and reappears when
        access returns.

        **Required Scopes:** `read:sites` (or `read:own_sites`). A token that
        names scopes without either is refused with 403
        `insufficient_permissions` ("Required scope: read:sites").
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Bookmarks per page; a value outside 1–100 is clamped to it.
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Bookmarks, most recent first
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/SitesBookmark"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            * `insufficient_permissions` — the token lacks the scope ("Required scope: read:sites").
            * `app_disabled` — the Sites app is not enabled for the business.
            * `app_access_denied` — the caller does not have access to the Sites app.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Sites app is not configured on this
            installation."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Sites
      summary: Bookmark a site or a page
      description: 'Idempotent: bookmarking something already bookmarked answers the
        existing bookmark (201), never removes it. Honours `Idempotency-Key`.'
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bookmarkable_type
              - bookmarkable_id
              properties:
                bookmarkable_type:
                  type: string
                  enum:
                  - Site
                  - SitePage
                bookmarkable_id:
                  type: integer
                  description: A site the caller can open, or a page they can read.
      responses:
        '201':
          description: The bookmark
          content:
            application/json:
              schema:
                type: object
                properties:
                  bookmark:
                    "$ref": "#/components/schemas/SitesBookmark"
        '401':
          description: Missing or invalid token.
        '403':
          description: The token lacks `write:sites`, or the Sites app is not enabled
            or not accessible to the caller.
        '404':
          description: "`not_found` — no site or page the caller can open has that
            id."
        '409':
          description: "`bookmark_conflict` — the bookmark could not be written; try
            again."
        '422':
          description: "`invalid_bookmarkable_type`, or a validation error."
  "/sites/bookmarks/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The bookmark's id (not the site's or page's).
      schema:
        type: integer
    delete:
      tags:
      - Sites
      summary: Remove one of the caller's bookmarks
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    type: string
                    example: Bookmark removed
        '401':
          description: Missing or invalid token.
        '403':
          description: The token lacks `write:sites`, or the Sites app is not enabled
            or not accessible to the caller.
        '404':
          description: "`not_found` — the caller has no bookmark with that id."
        '422':
          description: "`delete_failed`."
  "/sites/dashboard":
    get:
      tags:
      - Sites
      summary: The caller's Sites Dashboard — decisions, care and Your sites
      description: 'The three sections of the desktop Dashboard that need no period:
        the decisions waiting on the caller (approval requests to decide, their own
        requests sent back, approved pages they may publish, pages scheduled to publish),
        the problems Sites detects on the pages they look after, and the sites they
        own or edit with 30-day reads. A section with nothing in it is empty, never
        padded. Call `GET /sites/dashboard/performance` for the figures when `performance_available`
        is true.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The Dashboard
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    properties:
                      looks_after_pages:
                        type: boolean
                        description: Whether the caller looks after any page — a Sites
                          admin, or an owner or editor of a site, or a page's owner.
                          Care rows and the figures are only for them.
                      analytics_enabled:
                        type: boolean
                        description: The tenant records page views (`enable_analytics`,
                          on by default).
                      performance_available:
                        type: boolean
                        description: "`looks_after_pages` and `analytics_enabled`
                          — `GET /sites/dashboard/performance` has figures to give."
                      decisions:
                        type: object
                        description: '"Decisions waiting on you". Not drawn on the
                          web while `cards` and `snoozed` are both empty.'
                        properties:
                          cards:
                            type: array
                            items:
                              "$ref": "#/components/schemas/SitesDashboardDecisionCard"
                          snoozed:
                            type: array
                            description: Cards the caller snoozed on the web; each
                              comes back on its own when its set changes.
                            items:
                              type: object
                              properties:
                                key:
                                  type: string
                                  example: sites.reviews
                                title:
                                  type: string
                                  example: Pending approval
                      pages_needing_care:
                        type: object
                        nullable: true
                        description: '"Pages that need care". Null when `looks_after_pages`
                          is false. Empty `rows` means nothing needs care.'
                        properties:
                          scope:
                            type: string
                            enum:
                            - all_sites
                            - sites_and_pages_you_look_after
                            description: "`all_sites` for a Sites admin; otherwise
                              the sites the caller owns or edits and the pages they
                              own."
                          rows:
                            type: array
                            items:
                              "$ref": "#/components/schemas/SitesDashboardCareRow"
                      your_sites:
                        type: object
                        properties:
                          total:
                            type: integer
                            description: Every site the caller owns or edits.
                          has_more:
                            type: boolean
                            description: '`rows` holds the first 10 by name and `total`
                              is larger ("Showing 10 of N").'
                          reads_days:
                            type: integer
                            example: 30
                            description: The window `reads` covers — always 30 days,
                              whatever period the figures use.
                          rows:
                            type: array
                            maxItems: 10
                            items:
                              "$ref": "#/components/schemas/SitesDashboardSiteRow"
              example:
                dashboard:
                  looks_after_pages: true
                  analytics_enabled: true
                  performance_available: true
                  decisions:
                    cards:
                    - key: sites.reviews
                      title: Pending approval
                      count: 1
                      count_label:
                      tone: info
                      icon: fas fa-clipboard-check
                      lead: Working Here
                      lead_page_id: 812
                      lead_site_id: 64
                      sub: People & Culture · waiting 6 days
                      cta:
                        label: Open approvals
                        list: approvals
                        filter: mine
                        web_path: "/apps/sites/reviews?filter=mine"
                    snoozed: []
                  pages_needing_care:
                    scope: sites_and_pages_you_look_after
                    rows:
                    - key: stale
                      title: Going stale
                      count: 1
                      count_text: 1 page
                      detail: 'Not published or checked in 180 days · oldest: Quick
                        Start Guide, published 7 months ago'
                      cta_label: Review pages
                      queue: stale
                  your_sites:
                    total: 1
                    has_more: false
                    reads_days: 30
                    rows:
                    - site:
                        id: 70
                        name: Customer Success
                        display_name: Customer Success
                        slug: customer-success
                        site_type: department
                        status: published
                        is_published: true
                        icon: fa-headset
                        icon_color:
                        cover_image:
                      pages_count: 5
                      pages_needing_care: 2
                      reads: 1284
                      reads_previous: 1088
                      trend_percent: 18
                      last_published_at: '2026-10-09T10:12:00Z'
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`dashboard_not_available` — the Dashboard has nothing for
            the caller (they do not administer, build, approve or own pages; send
            them to All sites); `insufficient_permissions` — the token lacks `read:sites`;
            `app_disabled` / `app_access_denied` — the Sites app is off for the business
            or not open to the caller."
          content:
            application/json:
              example:
                error:
                  code: dashboard_not_available
                  message: The Sites Dashboard is for people who look after, approve
                    or build pages. Your sites are under All sites.
  "/sites/dashboard/performance":
    get:
      tags:
      - Sites
      summary: How the caller's pages are doing, over 7, 30 or 90 days
      description: 'The Dashboard''s figures over the pages the caller looks after
        (every page for a Sites admin): reads with the change against the same number
        of days before, readers (counted, never named), the questions Ask AI pulled
        these pages into, the three most read pages, the three pages Ask AI used most,
        and the pages nobody read — or, for a Sites admin with the knowledge-gap report
        on, the searches that found nothing. `performance` is null for a caller who
        looks after no page (an approver who builds and owns nothing).'
      security:
      - BearerAuth: []
      parameters:
      - name: period
        in: query
        required: false
        description: Days to cover — 7, 30 (the default) or 90. Any other value is
          422 `invalid_period`.
        schema:
          type: integer
          enum:
          - 7
          - 30
          - 90
          default: 30
      responses:
        '200':
          description: The figures
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance:
                    type: object
                    nullable: true
                    properties:
                      period_days:
                        type: integer
                        example: 30
                      periods:
                        type: array
                        items:
                          type: integer
                        example:
                        - 7
                        - 30
                        - 90
                      reads:
                        type: integer
                        description: Page opens in the period.
                      reads_previous:
                        type: integer
                        description: Page opens in the same number of days before
                          it.
                      trend_percent:
                        type: integer
                        nullable: true
                        description: '`reads` against `reads_previous`, in percent.
                          Null when there is nothing earlier to compare with. At or
                          above `trend_percent_cap` the web says "Up from <reads_previous>
                          in the <period_days> days before" instead of a percentage.'
                      trend_percent_cap:
                        type: integer
                        example: 1000
                      readers:
                        type: integer
                        description: People who opened at least one of these pages.
                      ai_questions:
                        type: integer
                        description: Questions Ask AI pulled these pages into.
                      most_read:
                        type: array
                        maxItems: 3
                        items:
                          type: object
                          properties:
                            page:
                              "$ref": "#/components/schemas/SitesDashboardPage"
                            reads:
                              type: integer
                      ai_pages:
                        type: array
                        maxItems: 3
                        items:
                          type: object
                          properties:
                            page:
                              "$ref": "#/components/schemas/SitesDashboardPage"
                            questions:
                              type: integer
                      unread:
                        type: object
                        nullable: true
                        description: Published before the period and opened by nobody
                          in it. Null when `search_gaps` is present.
                        properties:
                          count:
                            type: integer
                          pages:
                            type: array
                            maxItems: 3
                            items:
                              "$ref": "#/components/schemas/SitesDashboardPage"
                      search_gaps:
                        type: object
                        nullable: true
                        description: 'Sites admins with the knowledge-gap report on:
                          the searches that found nothing, over the report''s own
                          window (`period_days`), whatever `period` asked for. Null
                          for everyone else.'
                        properties:
                          period_days:
                            type: integer
                            example: 30
                          sufficient_data:
                            type: boolean
                            description: False while there are too few searches to
                              report on ("Not enough searches to report on yet").
                          queries:
                            type: array
                            maxItems: 3
                            items:
                              type: object
                              properties:
                                query:
                                  type: string
                                  example: parking permit
                                searches:
                                  type: integer
                                  example: 4
                                now_finds:
                                  type: integer
                                  description: Pages those words find today — above
                                    0, the gap has since been answered.
              example:
                performance:
                  period_days: 30
                  periods:
                  - 7
                  - 30
                  - 90
                  reads: 1284
                  reads_previous: 1088
                  trend_percent: 18
                  trend_percent_cap: 1000
                  readers: 96
                  ai_questions: 41
                  most_read:
                  - page:
                      id: 812
                      title: Team Directory
                      slug: team-directory
                      site:
                        id: 70
                        name: Customer Success
                        display_name: Customer Success
                    reads: 412
                  ai_pages:
                  - page:
                      id: 815
                      title: How We Work
                      slug: how-we-work
                      site:
                        id: 70
                        name: Customer Success
                        display_name: Customer Success
                    questions: 19
                  unread:
                    count: 1
                    pages:
                    - id: 818
                      title: Quick Start Guide
                      slug: quick-start-guide
                      site:
                        id: 70
                        name: Customer Success
                        display_name: Customer Success
                  search_gaps:
        '403':
          description: "`dashboard_not_available` — as `GET /sites/dashboard`; `analytics_disabled`
            — the tenant does not record page views; `insufficient_permissions` —
            the token lacks `read:sites`."
        '422':
          description: "`invalid_period` — `period` is not 7, 30 or 90."
  "/sites/approvals":
    get:
      tags:
      - Sites
      summary: The caller's Sites approval queue, by filter
      description: |-
        Pages sent for approval, filtered as the desktop chips are: `waiting` (Waiting on you — requests the caller can decide, their own requests sent back to them, and their approved pages ready to publish), `all` (All pending — every request the caller may see except their own), `mine` (Assigned to me — what they can decide; for a Sites admin, the requests named for them), `sent` (Sent by me — their own requests still waiting, sent back, or approved and not live yet) and `ready` (Ready to publish — the part of Sent by me they can publish now).

        Order: requests to decide oldest request first (the one holding someone up leads); Sent by me sent back first, then approved, then waiting, latest decision first; Waiting on you requests to decide, then sent back, then ready to publish.

        `filters` are the chips to draw, in order: Waiting on you and Ready to publish appear only while they hold something or are the selected filter. Each row is a request (`kind: request`) or the caller's own (`kind: sent`) and lists the `actions` the caller may take, in the order the web draws them. This endpoint lists; it decides nothing.
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: 'Which chip. Omitted, it opens where the Sites navigation''s
          Approvals item does: `waiting` while something waits on the caller, else
          `all`.'
        schema:
          type: string
          enum:
          - waiting
          - all
          - mine
          - sent
          - ready
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The filter's rows, the chips and their counts
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SitesApprovalRow"
                  filter:
                    type: string
                    enum:
                    - waiting
                    - all
                    - mine
                    - sent
                    - ready
                    description: The filter these rows are — the one asked for, or
                      the default.
                  filters:
                    type: array
                    description: The chips to draw, in order.
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                          enum:
                          - waiting
                          - all
                          - mine
                          - sent
                          - ready
                        label:
                          type: string
                          example: Waiting on you
                        count:
                          type: integer
                          description: Every row the chip lists
                          over all pages.:
                        selected:
                          type: boolean
                  counts:
                    type: object
                    description: Every filter's count, shown as a chip or not.
                    properties:
                      waiting:
                        type: integer
                      all:
                        type: integer
                      mine:
                        type: integer
                      sent:
                        type: integer
                      ready:
                        type: integer
                  waiting_label:
                    type: string
                    description: What the Waiting on you count is made of, for a screen
                      reader.
                    example: '3 waiting on you: 1 to decide, 1 sent back to you, 1
                      ready to publish'
                  empty_state:
                    nullable: true
                    description: The selected filter's empty state, worded for that
                      filter. Present only when the filter lists nothing at all (null
                      past the last page of a filter that has rows).
                    type: object
                    properties:
                      icon:
                        type: string
                        example: fas fa-clipboard-check
                      title:
                        type: string
                        example: No pages waiting for approval
                      text:
                        type: string
                        example: Everything sent for approval has been decided.
                      cta:
                        nullable: true
                        type: object
                        description: A button that opens another filter.
                        properties:
                          label:
                            type: string
                            example: Open Sent by me
                          filter:
                            type: string
                            enum:
                            - waiting
                            - all
                            - mine
                            - sent
                            - ready
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                  total_count:
                    type: integer
              example:
                items:
                - id: 412
                  kind: request
                  title: How We Work
                  renames:
                  page:
                    id: 412
                    title: How We Work
                    display_title: How We Work
                    slug: how-we-work
                    page_type: standard
                    status: published
                    published_at: '2026-09-02T15:04:00Z'
                    site:
                      id: 18
                      name: Customer Success
                      display_name: Customer Success
                      slug: customer-success
                      site_type: team
                      status: published
                      icon: fa-headset
                      icon_color:
                      tile_color: "#0E7490"
                  person:
                    id: 77
                    name: Nina Patel
                    avatar_url: https://officechat.workforce.mangoapps.com/rails/active_storage/representations/redirect/…
                  requested_by:
                    id: 77
                    name: Nina Patel
                  approver:
                    id: 54
                    name: Dana Whitfield
                  decided_by:
                  review:
                    status: pending_review
                    status_label:
                    badge: asked
                    wait_flag_after_days: 3
                    requested_at: '2026-10-16T09:12:00Z'
                    reviewed_at:
                    withdrawn_by_edit: false
                    approved_state:
                    note: Updated the escalation steps for the new on-call rota.
                    scheduled_publish_at:
                    schedule_text:
                    revision: 9f2c…
                  waiting_for:
                  actions:
                  - request_changes
                  - reject
                  - approve
                  last_published_version: 4
                filter: waiting
                filters:
                - key: waiting
                  label: Waiting on you
                  count: 1
                  selected: true
                - key: all
                  label: All pending
                  count: 1
                  selected: false
                - key: mine
                  label: Assigned to me
                  count: 1
                  selected: false
                - key: sent
                  label: Sent by me
                  count: 1
                  selected: false
                counts:
                  waiting: 1
                  all: 1
                  mine: 1
                  sent: 1
                  ready: 0
                waiting_label: '1 waiting on you: 1 to decide'
                empty_state:
                meta:
                  total_count: 1
                  total_pages: 1
                  current_page: 1
                  per_page: 20
                  has_next_page: false
                  has_prev_page: false
                total_count: 1
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          description: "`approvals_disabled` — page approvals are turned off for the
            tenant (the `enable_content_review` setting); `insufficient_permissions`
            — the token lacks `read:sites`; `app_disabled` / `app_access_denied` —
            the Sites app is off for the business or not open to the caller."
          content:
            application/json:
              example:
                error:
                  code: approvals_disabled
                  message: Page approvals are turned off for your company.
        '422':
          description: "`invalid_filter` — `filter` is not one of waiting, all, mine,
            sent, ready."
  "/sites/approvals/{id}/approve":
    post:
      tags:
      - Sites
      summary: Approve a page sent for approval
      description: |-
        The Approvals row's Approve. The note is optional; approving without one keeps the request note. Once approved, the page can be published.

        Who may decide: the approver the page was sent to, or a company admin or Sites app-admin — never whoever asked for the approval, and nobody while the page's site is in the Trash. Asked before anything about the page's state, so a caller who may not decide learns nothing about it. `review_revision` is the row's `review.revision`: a request that changed after the caller loaded it (an edit, a new approver, someone else's decision) is refused, never decided. Whoever asked, and the page's author, are notified as on the web. Send an `Idempotency-Key` header to make a retry safe.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SitesApprovalPageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SitesApprovalApproveRequest"
            example:
              review_revision: 9f2c…
              notes:
      responses:
        '200':
          description: Decided.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesApprovalDecision"
              example:
                id: 412
                decision: approve
                message: Page approved.
                title: How We Work
                requested_by:
                  id: 77
                  name: Nina Patel
                review:
                  status: approved
                  status_label: Approved
                  reviewed_at: '2026-10-19T10:02:00Z'
                  decided_by:
                    id: 54
                    name: Dana Whitfield
                  note:
                waiting_count: 2
                waiting_label: '2 waiting on you: 1 to decide, 1 ready to publish'
        '400':
          "$ref": "#/components/responses/SitesApprovalRevisionMissing"
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          "$ref": "#/components/responses/SitesApprovalDecisionForbidden"
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '409':
          "$ref": "#/components/responses/SitesApprovalDecisionConflict"
        '422':
          description: "`not_saved` — the page refused the change (the message says
            why)."
  "/sites/approvals/{id}/reject":
    post:
      tags:
      - Sites
      summary: Reject a page sent for approval
      description: |-
        The Approvals row's Reject — stop this version. A note saying why is required: the person who asked for approval is sent it.

        Who may decide: the approver the page was sent to, or a company admin or Sites app-admin — never whoever asked for the approval, and nobody while the page's site is in the Trash. Asked before anything about the page's state, so a caller who may not decide learns nothing about it. `review_revision` is the row's `review.revision`: a request that changed after the caller loaded it (an edit, a new approver, someone else's decision) is refused, never decided. Whoever asked, and the page's author, are notified as on the web. Send an `Idempotency-Key` header to make a retry safe.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SitesApprovalPageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SitesApprovalNoteRequest"
            example:
              review_revision: 9f2c…
              notes: We are retiring this policy.
      responses:
        '200':
          description: Decided.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesApprovalDecision"
              example:
                id: 412
                decision: reject
                message: Page rejected.
                title: How We Work
                requested_by:
                  id: 77
                  name: Nina Patel
                review:
                  status: rejected
                  status_label: Rejected
                  reviewed_at: '2026-10-19T10:02:00Z'
                  decided_by:
                    id: 54
                    name: Dana Whitfield
                  note: We are retiring this policy.
                waiting_count: 2
                waiting_label: '2 waiting on you: 1 to decide, 1 ready to publish'
        '400':
          "$ref": "#/components/responses/SitesApprovalRevisionMissing"
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          "$ref": "#/components/responses/SitesApprovalDecisionForbidden"
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '409':
          "$ref": "#/components/responses/SitesApprovalDecisionConflict"
        '422':
          description: "`note_required` — no note (or only spaces); `not_saved` —
            the page refused the change."
  "/sites/approvals/{id}/request_changes":
    post:
      tags:
      - Sites
      summary: Request changes on a page sent for approval
      description: |-
        The Approvals row's Request changes — send it back with what to fix. A note is required: the person who asked for approval is sent it.

        Who may decide: the approver the page was sent to, or a company admin or Sites app-admin — never whoever asked for the approval, and nobody while the page's site is in the Trash. Asked before anything about the page's state, so a caller who may not decide learns nothing about it. `review_revision` is the row's `review.revision`: a request that changed after the caller loaded it (an edit, a new approver, someone else's decision) is refused, never decided. Whoever asked, and the page's author, are notified as on the web. Send an `Idempotency-Key` header to make a retry safe.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SitesApprovalPageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SitesApprovalNoteRequest"
            example:
              review_revision: 9f2c…
              notes: Add the on-call number.
      responses:
        '200':
          description: Decided.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SitesApprovalDecision"
              example:
                id: 412
                decision: request_changes
                message: Changes requested.
                title: How We Work
                requested_by:
                  id: 77
                  name: Nina Patel
                review:
                  status: changes_requested
                  status_label: Changes requested
                  reviewed_at: '2026-10-19T10:02:00Z'
                  decided_by:
                    id: 54
                    name: Dana Whitfield
                  note: Add the on-call number.
                waiting_count: 2
                waiting_label: '2 waiting on you: 1 to decide, 1 ready to publish'
        '400':
          "$ref": "#/components/responses/SitesApprovalRevisionMissing"
        '401':
          description: "`unauthorized` — no or an invalid token."
        '403':
          "$ref": "#/components/responses/SitesApprovalDecisionForbidden"
        '404':
          description: "`not_found` — no page with that id in the caller's company."
        '409':
          "$ref": "#/components/responses/SitesApprovalDecisionConflict"
        '422':
          description: "`note_required` — no note (or only spaces); `not_saved` —
            the page refused the change."
  "/countries":
    get:
      tags:
      - Countries
      summary: List the business's operating countries
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Countries, by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  countries:
                    type: array
                    items:
                      "$ref": "#/components/schemas/OperatingCountry"
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      configured:
                        type: boolean
                        description: Whether the business chose its countries (false
                          — `countries` is every country).
                      primary_country:
                        type: string
                        nullable: true
                        example: US
        '401':
          description: Authentication required
  "/countries/all":
    get:
      tags:
      - Countries
      summary: List every available country (admin configuration)
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Every country, with the business's enabled codes
          content:
            application/json:
              schema:
                type: object
                properties:
                  countries:
                    type: array
                    items:
                      "$ref": "#/components/schemas/OperatingCountry"
                  enabled_codes:
                    type: array
                    items:
                      type: string
                    example:
                    - US
                    - CA
                    - DE
                  primary_country:
                    type: string
                    nullable: true
        '401':
          description: Authentication required
  "/policy_hub/policies":
    get:
      tags:
      - Policy Hub
      summary: List policies
      description: |
        The published policies the caller may read — a read-restricted policy is
        left out for everyone outside its readers, its acknowledgers and HR —
        most recently changed first (`updated_at` descending, then `id`
        descending, so pages never skip or repeat a policy). Each entry is the
        policy's summary; open `GET /policy_hub/policies/{id}` for its content,
        who it applies to and the caller's acknowledgment state. Policy Hub
        must be enabled for the business and accessible to the caller.

        **Required Scopes:** `read:policy_hub` (#7183) — the scope of every
        Policy Hub read. A token without it gets `403 insufficient_permissions`
        ("Required scope: read:policy_hub") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        required: false
        description: Only policies in this category (exact match). Repeat as `category[]=`
          to accept any of several. Empty means every category; an unknown category
          returns an empty list.
        schema:
          type: string
      - name: search
        in: query
        required: false
        description: Words to look for. Each word (split on spaces) must appear, case-insensitively,
          in the policy's title or description. Empty means no search.
        schema:
          type: string
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Policies per page. Values above 100 are clamped to 100; an empty,
          zero, negative or non-numeric value uses the default.
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of policies
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/PolicyHubPolicySummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:policy_hub"
            — the token lacks the scope), `feature_not_enabled` (Policy Hub is not
            enabled for the business) or `app_forbidden` (the caller has no access
            to Policy Hub).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/policy_hub/handbooks":
    get:
      tags:
      - Policy Hub
      summary: List handbooks
      description: |
        The published handbooks aimed at the caller — those for all employees,
        and those targeted at a department or location the caller belongs to
        (a location rollup includes the stores under it) — most recently
        changed first (`updated_at` descending, then `id` descending). Each
        entry is the handbook's summary; open `GET /policy_hub/handbooks/{id}`
        for its introduction and sections. Policy Hub must be enabled for the
        business and accessible to the caller.

        **Required Scopes:** `read:policy_hub` (#7183) — the scope of every
        Policy Hub read. A token without it gets `403 insufficient_permissions`
        ("Required scope: read:policy_hub") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Handbooks per page. Values above 100 are clamped to 100; an empty,
          zero, negative or non-numeric value uses the default.
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of handbooks
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/PolicyHubHandbookSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:policy_hub"
            — the token lacks the scope), `feature_not_enabled` (Policy Hub is not
            enabled for the business) or `app_forbidden` (the caller has no access
            to Policy Hub).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/policy_hub/policies/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Policy Hub
      summary: Read one policy
      description: |
        A published policy the caller may read, with their own acknowledgment
        state. Reading it records a view where the policy asks the caller for an
        acknowledgment. Policy Hub must be enabled for the business and
        accessible to the caller.

        **Required Scopes:** `read:policy_hub` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope") — the scope of every Policy Hub read
        (policies, handbooks, your acknowledgments). Acknowledging a policy
        (`POST /policy_hub/policies/{id}/acknowledge`) asks `write:notifications`
        instead, the scope every acknowledgment asks (#5068). A token without the
        scope gets `403 insufficient_permissions` ("Required scope: …") before
        anything else is checked. The sign-in tokens hold both; a browser session
        is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: 'The policy, under a `policy` key and nothing else (`render_single`:
            no `unread_notification_count`, no `_meta`).'
          content:
            application/json:
              schema:
                type: object
                required:
                - policy
                properties:
                  policy:
                    "$ref": "#/components/schemas/PolicyHubPolicyDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: "`insufficient_permissions` (the token lacks `read:policy_hub`),
            `feature_not_enabled` or `app_forbidden`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — no published policy the caller may read: "The
            requested resource was not found." for an id that does not exist in the
            business or a read-restricted policy the caller may not read, "Policy
            not found." for one that is not published (draft, archived, retired) and
            never asked the caller (they hold no acknowledgment row on it — otherwise
            `410`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '410':
          description: '`policy_withdrawn` — the policy is no longer published (a
            draft, archived or retired) and the caller holds an acknowledgment row
            on it, so it once asked them. `error.message` says why: "This policy isn''t
            published right now, so there is nothing to acknowledge until HR publishes
            it again." for a draft, "This policy is no longer in effect, so there
            is nothing to acknowledge." otherwise. Do not retry; drop the item. Anyone
            without a row gets the `404` instead.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/policy_hub/policies/{id}/acknowledge":
    parameters:
    - name: id
      in: path
      required: true
      description: The POLICY's id (from the list, the detail or `pending[].id`).
      schema:
        type: integer
    post:
      tags:
      - Policy Hub
      summary: Acknowledge (accept) a policy
      description: |
        The caller accepts the current version of a published policy that asks
        them for an acknowledgment. This is a signature: the request's IP
        address, user agent and device are recorded with it (door `api`,
        signature method `click`), the row is pinned to the policy's current
        version, marked viewed if it was not yet, and any re-acknowledgment
        flag and deadline are cleared. No request body.

        **The response is the acknowledgment row, not the policy:**
        `{ "acknowledgment": PolicyHubAcknowledgment }` — `acknowledgment.id` is
        the acknowledgment's own id and `acknowledgment.policy_id` the policy's.
        It carries `acknowledged_at` and `viewed_at` (the same names
        `GET /policy_hub/policies/{id}` uses; there is no `first_viewed_at`),
        so a client can repaint the reader's "Your Activity" section from it
        without re-reading the policy. Like the policy detail it is rendered
        bare — no `unread_notification_count`, no `_meta`.

        **Idempotent.** A policy the caller has already accepted (and owes no
        re-acknowledgment for) answers `200` with the existing acknowledgment
        unchanged; a row HR flagged for re-acknowledgment is accepted again.

        Checked in this order, each refusal stopping the request: the token's
        scope, Policy Hub enabled and accessible, a View-as session
        (`view_as`), the policy exists and the caller may read it, it is
        published (`410 policy_withdrawn` for someone it once asked), it asks
        for an acknowledgment (`not_applicable`), then — before any gate below
        — a caller who already accepted it and is not asked again gets `200`
        with that acknowledgment, it is for the caller (`forbidden` — asked
        only when the caller has no acknowledgment row yet: an existing row is
        the assignment and is always completable), required training is
        complete (`training_required`), a
        comprehension check is passed (`comprehension_required` — there is no
        quiz in this API; take it on the web), and it is not a policy the
        caller must SIGN (`esignature_required`, with `details.sign_url`).
        `GET /policy_hub/policies/{id}` names these steps in advance
        (`requires_signature`, `comprehension_required`, `required_training`).

        **Required Scopes:** `write:notifications` — the scope every
        acknowledgment asks (#5068), not `read:policy_hub` and not
        `write:policy_hub`. A token without it gets `403
        insufficient_permissions` ("Required scope: write:notifications")
        before anything else is checked. The sign-in tokens hold it; a browser
        session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: 'The caller''s acknowledgment of the policy, as now stored
            — `acknowledged: true` with `acknowledged_at` and `viewed_at` set.'
          content:
            application/json:
              schema:
                type: object
                required:
                - acknowledgment
                properties:
                  acknowledgment:
                    "$ref": "#/components/schemas/PolicyHubAcknowledgment"
              example:
                acknowledgment:
                  id: 313372
                  policy_id: 60
                  policy_title: Code of Conduct
                  acknowledged: true
                  acknowledged_at: '2026-10-09T09:03:11.482-04:00'
                  viewed_at: '2026-10-09T09:01:47.106-04:00'
                  requires_re_acknowledgment: false
                  signed_earlier_version: false
                  deadline:
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:notifications"),
            `feature_not_enabled` (Policy Hub is not enabled for the business), `app_forbidden`
            (the caller has no access to Policy Hub), `view_as` (an administrator
            viewing as this person — only they can acknowledge or sign a policy, signed
            in as themselves) or `forbidden` ("This policy does not apply to you."
            — the caller has no acknowledgment row for it and is not in its audience).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — "The requested resource was not found." for
            an id that does not exist in the business or a read-restricted policy
            the caller may not read; "Policy not found." for one that is not published
            and never asked the caller (no acknowledgment row — otherwise `410`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '410':
          description: '`policy_withdrawn` — the policy is no longer published (a
            draft, archived or retired) and the caller holds an acknowledgment row
            on it, so it once asked them. `error.message` says why: "This policy isn''t
            published right now, so there is nothing to acknowledge until HR publishes
            it again." for a draft, "This policy is no longer in effect, so there
            is nothing to acknowledge." otherwise. Do not retry; drop the item. Anyone
            without a row gets the `404` instead.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: '`conflict` — "We couldn''t record your acknowledgment. Please
            try again." A concurrent request created the acknowledgment row at the
            same moment and it could not be re-read. Retry.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Not accepted. The standard error envelope with one of these codes:

            * `not_applicable` — "This policy does not require acknowledgment."
            * `training_required` — "You must complete the required training
              before acknowledging this policy: <titles>." When the caller
              cannot open the Training app, the message goes on to say why and
              who can fix it (the detail's `training_access_notice`).
              `error.details.policy_url` is the policy page listing the courses.
            * `comprehension_required` — the policy has a comprehension check
              the caller has not passed; complete it from the web app.
            * `esignature_required` — "This policy asks for your signature.
              Open it in Policy Hub to sign." `error.details.sign_url` is the
              absolute URL of the signing step
              (`/apps/policy-hub/acknowledge/{id}/sign`, which continues a
              signing already under way); open it in a browser.
            * `acknowledgment_failed` — "We couldn't record your
              acknowledgment. Please try again."

            Or, when the acknowledgment row fails validation, the validation
            envelope `{ "errors": [ { "field", "message", "code":
            "validation_error" } ] }` instead.
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/Error"
                - "$ref": "#/components/schemas/ValidationErrors"
              examples:
                esignature_required:
                  value:
                    error:
                      code: esignature_required
                      message: This policy asks for your signature. Open it in Policy
                        Hub to sign.
                      details:
                        sign_url: https://acme.workforce.mangoapps.com/apps/policy-hub/acknowledge/60/sign
                not_applicable:
                  value:
                    error:
                      code: not_applicable
                      message: This policy does not require acknowledgment.
  "/policy_hub/handbooks/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Policy Hub
      summary: Read one handbook
      description: |
        A published handbook aimed at the caller — exactly the handbooks
        `GET /policy_hub/handbooks` lists (for all employees, or targeted at a
        department or location the caller belongs to) — with its introduction
        and its sections in order. A policy section names its policy by
        `policy_id` and carries no body: open it at
        `GET /policy_hub/policies/{policy_id}`. A policy section whose policy is
        no longer published, or that the caller may not read, is left out, so
        every `policy_id` here opens. Policy Hub must be enabled for the
        business and accessible to the caller.

        **Required Scopes:** `read:policy_hub` (#7183) — the scope of every
        Policy Hub read. A token without it gets `403 insufficient_permissions`
        ("Required scope: read:policy_hub") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: 'The handbook, under a `handbook` key and nothing else (`render_single`:
            no `unread_notification_count`, no `_meta`).'
          content:
            application/json:
              schema:
                type: object
                required:
                - handbook
                properties:
                  handbook:
                    "$ref": "#/components/schemas/PolicyHubHandbookDetail"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:policy_hub"
            — the token lacks the scope), `feature_not_enabled` (Policy Hub is not
            enabled for the business) or `app_forbidden` (the caller has no access
            to Policy Hub).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` — "Handbook not found." The same answer for an
            id that does not exist, a handbook that is not published, and one not
            aimed at the caller.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/policy_hub/acknowledgments":
    get:
      tags:
      - Policy Hub
      summary: Your acknowledgments (pending and history)
      description: |
        The caller's compliance inbox, as two independently paged collections
        in one response:

        * `pending` — the published policies still asking the caller for an
          acknowledgment: never accepted, or flagged by HR for
          re-acknowledgment, and only while the policy's CURRENT audience
          includes the caller. Each entry is the policy summary (its `id` is the
          POLICY id — acknowledge it at
          `POST /policy_hub/policies/{id}/acknowledge`) plus the deadline state
          of the acknowledgment that makes it pending. Most recently changed
          policy first (policy `updated_at` descending, then policy `id`
          descending). Paged by `pending_page`; `pending_meta` describes it.
        * `history` — the caller's acknowledgments (`acknowledged: true`),
          most recently accepted first (`acknowledged_at` descending, then
          `id` descending). It includes a row HR has since flagged; read
          `requires_re_acknowledgment` (asked again — the policy also appears
          in `pending`) and `signed_earlier_version` (flagged, but no longer
          asked). Its policy may since have been unpublished, in which case
          `GET /policy_hub/policies/{policy_id}` answers 404. Paged by `page`;
          `meta`, the root `total_count` and the `X-Total-Count` /
          `X-Total-Pages` / `X-Current-Page` / `X-Per-Page` headers describe it.

        * `campaign_packages` — HR's signature campaigns waiting for the
          caller: a campaign asks for policies the caller may hold no
          acknowledgment for, so they are not in `pending`. Each is signed in
          the browser at its `sign_path`. Most recent first, at most `per_page`.

        One `per_page` sizes both pages. Policy Hub must be enabled for the
        business and accessible to the caller.

        **Required Scopes:** `read:policy_hub` (#7183) — the scope of every
        Policy Hub read. A token without it gets `403 insufficient_permissions`
        ("Required scope: read:policy_hub") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        description: Page of `history`.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: pending_page
        in: query
        required: false
        description: Page of `pending` — its own cursor, independent of `page`.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page of BOTH `pending` and `history`. Values above 100
          are clamped to 100; an empty, zero, negative or non-numeric value uses the
          default.
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: One page of each collection
          content:
            application/json:
              schema:
                type: object
                required:
                - pending
                - pending_meta
                - history
                - meta
                - total_count
                properties:
                  campaign_packages:
                    type: array
                    description: Signature campaigns waiting for the caller (not in
                      `pending`).
                    items:
                      type: object
                      required:
                      - package_id
                      - campaign
                      - sign_path
                      properties:
                        package_id:
                          type: integer
                        campaign:
                          type: string
                          description: The campaign's name.
                        sign_path:
                          type: string
                          description: Where the caller signs it, in the browser.
                  pending:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PolicyHubPendingAcknowledgment"
                  pending_meta:
                    allOf:
                    - "$ref": "#/components/schemas/CollectionPaginationMeta"
                    description: Pagination of `pending` (cursor `pending_page`).
                  history:
                    type: array
                    items:
                      "$ref": "#/components/schemas/PolicyHubAcknowledgment"
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/CollectionPaginationMeta"
                    description: Pagination of `history` (cursor `page`).
                  total_count:
                    type: integer
                    description: The caller's accepted acknowledgments across all
                      pages (`meta.total_count`).
                  unread_notification_count:
                    "$ref": "#/components/schemas/UnreadNotificationCount"
                  _meta:
                    "$ref": "#/components/schemas/PlatformHttpMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:policy_hub"
            — the token lacks the scope), `feature_not_enabled` (Policy Hub is not
            enabled for the business) or `app_forbidden` (the caller has no access
            to Policy Hub).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/sop_hub/dashboard":
    get:
      tags:
      - SOP Hub
      summary: SOP Hub home for the caller
      description: |
        Counts, the caller's top five pending completions (soonest deadline
        first), five most recent completions, up to five manuals they may open,
        and what they may do in SOP Hub. SOP Hub must be enabled for the
        business and open to the caller (403 `app_not_enabled` /
        `app_not_accessible`).

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The dashboard.
          content:
            application/json:
              schema:
                type: object
                properties:
                  admin:
                    type: object
                    nullable: true
                    description: The author's panel — the web dashboard's own numbers,
                      over the caller's reach. Present only for a SOP Hub author (HR,
                      an admin, a SOP Hub app admin, or one limited to places and
                      departments); null for everyone else.
                    properties:
                      library:
                        "$ref": "#/components/schemas/SopHubStatusCounts"
                      manuals:
                        type: object
                        properties:
                          total:
                            type: integer
                          published:
                            type: integer
                      completions:
                        type: object
                        properties:
                          completed:
                            type: integer
                            description: 'The Completions card''s number: signed off
                              where sign-off is required, by current members, on published
                              SOPs.'
                          pending:
                            type: integer
                            description: Owed by current members on published SOPs
                              that ask for completion.
                          awaiting_sign_off:
                            type: integer
                            description: The Awaiting Verification list's count (`GET
                              /sop_hub/completions?status=pending_verification`).
                      recent_sops:
                        type: array
                        description: The five most recently changed SOPs, any status.
                        items:
                          "$ref": "#/components/schemas/SopHubLibraryRow"
                  counts:
                    type: object
                    properties:
                      pending:
                        type: integer
                      overdue:
                        type: integer
                      completed:
                        type: integer
                        description: Signed off where sign-off is required.
                      published_sops:
                        type: integer
                      manuals:
                        type: integer
                        description: The published manuals meant for the caller (an
                          author's own drafts are counted under `admin.manuals`).
                  my_pending:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubCompletion"
                  recently_completed:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubCompletion"
                  manuals:
                    type: array
                    description: 'The Procedure Manuals cards, up to five by title:
                      the published manuals meant for the caller and, for a SOP Hub
                      author, also the manuals they manage in every status (`status`
                      labels a draft or an archived one; `GET /sop_hub/manuals/{id}`
                      opens it for them). `sop_count` counts every SOP section of
                      a manual the caller manages, and the sections they may open
                      of any other.'
                    items:
                      "$ref": "#/components/schemas/SopHubManual"
                  capabilities:
                    type: object
                    properties:
                      can_view_reports:
                        type: boolean
                      can_manage_library:
                        type: boolean
                      can_verify:
                        type: boolean
                      can_remind:
                        type: boolean
                        description: An author
                        with completion reminders switched on.:
                      can_manage_settings:
                        type: boolean
        '401':
          description: No or invalid token.
        '403':
          description: Missing scope
          or SOP Hub is off or closed to the caller.:
  "/sop_hub/completions/my_pending":
    get:
      tags:
      - SOP Hub
      summary: My pending completions
      description: |
        What the caller still owes, newest first — the list desktop My SOPs and
        the phone page show.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of completions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubCompletion"
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
  "/sop_hub/completions/my_history":
    get:
      tags:
      - SOP Hub
      summary: My completion history
      description: |
        Every completion the caller has submitted, newest first. Keeps a record
        whose SOP was since archived or retired (`sop.status` says so) and one
        re-opened for re-completion (`status` says so).

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of completions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubCompletion"
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
  "/sop_hub/sops/{id}":
    get:
      tags:
      - SOP Hub
      summary: Read a SOP
      description: |
        A published SOP the caller may read, in the requested language (`lang`,
        else their own, else English), with the caller's own completion. A SOP
        outside their audience opens read-only (`capabilities.reference_only`);
        an optional SOP (no completion required) in their audience can be worked
        through but is never owed.
        A SOP that is not published (a draft, or withdrawn) answers 422 `sop_not_published`; one they may not read
        answers 404.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      - name: lang
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: The SOP.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sop:
                    allOf:
                    - "$ref": "#/components/schemas/SopHubSopSummary"
                    - type: object
                      properties:
                        language:
                          type: string
                        available_languages:
                          type: array
                          items:
                            type: object
                            properties:
                              code:
                                type: string
                              name:
                                type: string
                        steps:
                          type: array
                          items:
                            type: object
                            properties:
                              position:
                                type: integer
                              title:
                                type: string
                              content:
                                type: string
                                nullable: true
                              estimated_minutes:
                                type: integer
                                nullable: true
                              verification_required:
                                type: boolean
                              media_url:
                                type: string
                                nullable: true
                              safety:
                                "$ref": "#/components/schemas/SopHubStepSafety"
                              completed:
                                type: boolean
                              changed_since_my_last_completion:
                                type: boolean
                                description: The step was added or changed since the
                                  version the caller completed (the web's "Changed
                                  since your last completion" badge).
                  my_completion:
                    "$ref": "#/components/schemas/SopHubCompletion"
                  capabilities:
                    type: object
                    properties:
                      can_complete:
                        type: boolean
                      reference_only:
                        type: boolean
                  required_training:
                    type: array
                    description: Required courses the caller has not finished.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        met:
                          type: boolean
        '404':
          description: Not found
          or not one the caller may read.:
        '422':
          description: '`sop_not_published` — the SOP is not published: still a draft
            ("isn''t published right now") or withdrawn ("no longer in use").'
  "/sop_hub/sops/{id}/start":
    post:
      tags:
      - SOP Hub
      summary: Start a SOP
      description: |
        Creates the caller's completion (or returns the one they have).
        `completion_method` is set by the server. Refusals: 403
        `not_in_audience`, 403 `attestation_not_delegable` (View-as or an
        impersonation token), 422 `training_prerequisite_required`.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      responses:
        '200':
          description: The caller's completion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completion:
                    "$ref": "#/components/schemas/SopHubCompletion"
        '403':
          description: Refused
          see description.:
        '422':
          description: Refused
          see description.:
  "/sop_hub/sops/{id}/steps/{step_position}/complete":
    post:
      tags:
      - SOP Hub
      summary: Complete a step
      description: |
        Records step `step_position` (0-based). Idempotent: completing the same
        position again replaces its record. Starts the completion first when the
        SOP applies to the caller. Refusals as for start, plus 422 `invalid_step`
        and 422 `notes_too_long`.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      - name: step_position
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
      responses:
        '200':
          description: 'The updated completion. A SOP with no read-and-understood
            acknowledgement is submitted by its last step: `submitted` is true and
            `message` says so. A step on work already on the record answers `already_recorded:
            true` with its `message`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  completion:
                    "$ref": "#/components/schemas/SopHubCompletion"
                  submitted:
                    type: boolean
                  already_recorded:
                    type: boolean
                  message:
                    type: string
        '422':
          description: Refused.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/sops/{id}/complete":
    post:
      tags:
      - SOP Hub
      summary: Submit a SOP
      description: |
        Submits the caller's completion once every step is recorded. A SOP that
        asks for an acknowledgement needs `acknowledged: true` (a JSON boolean, or
        the string "true" in a form body). Refusals (422):
        `not_started`, `steps_incomplete`, `acknowledgement_required`,
        `completion_failed`, plus those of start. A SOP that needs sign-off
        answers `status: pending_verification`. A repeat of a submit that already
        counted (a second tap, a replay, one waiting for its sign-off) is a 200
        with `already_recorded: true` and the web's sentence in `message`.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                acknowledged:
                  type: boolean
      responses:
        '200':
          description: The submitted completion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completion:
                    "$ref": "#/components/schemas/SopHubCompletion"
                  already_recorded:
                    type: boolean
                    description: True when the work was already on the record; nothing
                      new was written.
                  message:
                    type: string
        '422':
          description: Refused.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/manuals":
    get:
      tags:
      - SOP Hub
      summary: Manuals I may open
      description: |
        Published procedure manuals aimed at the caller, newest change first. A
        SOP Hub author gets, by title, those and also the manuals they manage in
        every status (the web's manuals list) — `status` labels a draft or an
        archived one, `GET /sop_hub/manuals/{id}` opens it for them, and
        `sop_count` counts every SOP section of a manual they manage (the
        sections they may open of any other).

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of manuals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  manuals:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubManual"
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
  "/sop_hub/manuals/{id}":
    get:
      tags:
      - SOP Hub
      summary: Read a manual
      description: |
        A published manual meant for the caller, with its sections in order; a
        section whose SOP the caller cannot open is left out. Someone who manages
        the manual (a SOP Hub author, inside a scoped admin's reach) opens it in
        ANY status — a draft or an archived one — with every section, as the web
        manual page shows it to them, and gets its `audience_targets` (`can_manage` true).

        The text under a SOP section's card on the web and the phone is the
        procedure's own description: `sop.description`.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The manual.
          content:
            application/json:
              schema:
                type: object
                properties:
                  manual:
                    allOf:
                    - "$ref": "#/components/schemas/SopHubManual"
                    - type: object
                      properties:
                        created_by:
                          "$ref": "#/components/schemas/SopHubPerson"
                        footer:
                          type: string
                          nullable: true
                          description: The manual's footer text.
                        created_at:
                          type: string
                          format: date-time
                          description: The Overview's Created.
                        updated_at:
                          type: string
                          format: date-time
                          description: The Overview's Last Updated.
                        is_public:
                          type: boolean
                          description: 'The header''s Public label: the public link
                            opens — the manual is published, shared, has a link, and
                            the business allows public SOP and manual URLs.'
                        can_manage:
                          type: boolean
                          description: The caller manages this manual (any status
                            opens; every section is listed).
                        audience_targets:
                          type: object
                          nullable: true
                          description: Who the manual is for — only when `can_manage`.
                            Everyone, or the departments, locations and roles picked
                            (a reader must match every axis picked); none picked and
                            not everyone means nobody.
                          properties:
                            all_employees:
                              type: boolean
                            departments:
                              type: array
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                            locations:
                              type: array
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                            roles:
                              type: array
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                  name:
                                    type: string
                        sections:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              position:
                                type: integer
                              section_type:
                                type: string
                                description: Open set; today sop, custom, chapter_header,
                                  divider.
                              title:
                                type: string
                                nullable: true
                              content:
                                type: string
                                nullable: true
                              show_in_toc:
                                type: boolean
                              sop:
                                description: On a `sop` section — the procedure, with
                                  its steps for an expanded card; open it with `GET
                                  /sop_hub/sops/{id}`.
                                allOf:
                                - "$ref": "#/components/schemas/SopHubSopSummary"
                                - type: object
                                  properties:
                                    duration_minutes:
                                      type: integer
                                      description: The SOP's duration
                                      or the sum of its steps' estimates.:
                                    steps:
                                      type: array
                                      items:
                                        allOf:
                                        - "$ref": "#/components/schemas/SopHubStep"
                                        - type: object
                                          properties:
                                            position:
                                              type: integer
        '404':
          description: Not found
          or not one the caller may open.:
  "/sop_hub/manuals/{id}/pdf":
    get:
      tags:
      - SOP Hub
      summary: Download a manual as a PDF
      description: |
        The manual as the web's Download PDF prints it: the same manuals as
        `GET /sop_hub/manuals/{id}`, in its document theme. A published manual
        prints the procedures a reader may open, for everyone; only someone who
        manages an unpublished manual gets every section, each procedure readers
        would not get stamped with its status.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The PDF, sent as an attachment.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '404':
          description: Not found
          or not one the caller may open.:
  "/sop_hub/browse":
    get:
      tags:
      - SOP Hub
      summary: Browse SOPs
      description: |
        Every published SOP the caller may read, most recently changed first —
        the web's Browse SOPs, open to every member. `assigned_to_me` says the
        caller owes it (it opens for completion with `GET /sop_hub/sops/{id}`).
        An optional SOP in the caller's audience is never `assigned_to_me` but can
        still be worked through; a SOP outside their audience opens read-only. `filters.sop_types` are the type pills,
        each counted with the search applied.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description:
        tags).:
      - name: sop_type
        in: query
        required: false
        schema:
          type: string
        description: One type; an unknown type is ignored.
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of SOPs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sops:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/SopHubLibraryRow"
                      - type: object
                        properties:
                          assigned_to_me:
                            type: boolean
                  filters:
                    type: object
                    properties:
                      sop_types:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SopHubFilterOption"
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
  "/sop_hub/sops":
    get:
      tags:
      - SOP Hub
      summary: The SOP library
      description: |
        Every SOP the caller authors, most recently changed first, with each
        one's assigned and completed counts (people in its current audience).

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
        description: draft, published, archived, retired (open set).
      - name: sop_type
        in: query
        required: false
        schema:
          type: string
      - name: category
        in: query
        required: false
        schema:
          type: string
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Search text.
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of SOPs, the status chips' counts and the filter sheet's
            options.
          content:
            application/json:
              schema:
                type: object
                properties:
                  counts:
                    "$ref": "#/components/schemas/SopHubStatusCounts"
                  filters:
                    type: object
                    description: The filter sheet's Type and Category options, in
                      the order the web lists them, each counted with the caller's
                      other filters applied (its own left out) — the web list's facet
                      rule.
                    properties:
                      sop_types:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SopHubFilterOption"
                      categories:
                        type: array
                        items:
                          "$ref": "#/components/schemas/SopHubFilterOption"
                  sops:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/SopHubLibraryRow"
                      - type: object
                        properties:
                          completion_counts:
                            type: object
                            properties:
                              assigned:
                                type: integer
                              completed:
                                type: integer
                                description: Signed off where sign-off is required.
                          can_remind:
                            type: boolean
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
        '403':
          description: Not an author
          or a missing scope.:
  "/sop_hub/sops/{id}/admin":
    get:
      tags:
      - SOP Hub
      summary: A SOP as its author sees it
      description: |
        One SOP in any status — the web author page: its steps (a draft's or an
        archived SOP's too), the In Short summary, the Completion Stats card, the
        Details card, who owns it, its QR code and its five latest versions.
        `stats` counts the completions of people in the SOP's current audience;
        completed + awaiting_sign_off + pending = total.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      responses:
        '200':
          description: The SOP.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sop:
                    allOf:
                    - "$ref": "#/components/schemas/SopHubLibraryRow"
                    - type: object
                      properties:
                        created_at:
                          type: string
                          format: date-time
                        created_by:
                          "$ref": "#/components/schemas/SopHubPerson"
                        requires_completion:
                          type: boolean
                        duration_minutes:
                          type: integer
                          nullable: true
                          description: The SOP's duration, or the sum of its steps'
                            estimates; null when neither is set.
                        verification:
                          type: object
                          properties:
                            required:
                              type: boolean
                              description: A finished completion waits for sign-off.
                            whole_sop:
                              type: boolean
                              description: The SOP itself asks for sign-off.
                            steps:
                              type: array
                              items:
                                type: integer
                              description: 'Step numbers that ask for sign-off: each
                                is a step''s `position` + 1.'
                        audience:
                          type: string
                          description: Who it is for, in the web's words and the caller's
                            language ("Everyone" when it applies to all employees).
                        ai_summary:
                          type: string
                          nullable: true
                          description: Machine-written summary of the steps; the steps
                            are the procedure. Null when none was written.
                        owner:
                          type: object
                          properties:
                            person:
                              "$ref": "#/components/schemas/SopHubPerson"
                            group:
                              type: object
                              nullable: true
                              properties:
                                id:
                                  type: integer
                                name:
                                  type: string
                        qr_code:
                          type: string
                          nullable: true
                          description: Null while the tenant's QR code access is off.
                        steps:
                          type: array
                          items:
                            allOf:
                            - "$ref": "#/components/schemas/SopHubStep"
                            - type: object
                              properties:
                                position:
                                  type: integer
                  stats:
                    type: object
                    properties:
                      total:
                        type: integer
                      completed:
                        type: integer
                        description: Signed off where sign-off is required.
                      awaiting_sign_off:
                        type: integer
                      pending:
                        type: integer
                        description: Not finished
                        or owed again.:
                      rate:
                        type: number
                        description: Completed as a percent of total, one decimal;
                          0 when total is 0.
                  versions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubVersion"
                  total_versions:
                    type: integer
                    description: All versions; read them with GET /sop_hub/sops/{id}/versions.
        '403':
          description: Not an author
          or a missing scope.:
        '404':
          description: Not found
          or outside the caller's reach.:
  "/sop_hub/sops/{id}/versions":
    get:
      tags:
      - SOP Hub
      summary: A SOP's version history
      description: |
        Newest version first.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: The versions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sop:
                    "$ref": "#/components/schemas/SopHubSopSummary"
                  versions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubVersion"
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
        '404':
          description: Not found
          or outside the caller's reach.:
  "/sop_hub/sops/{id}/versions/compare":
    get:
      tags:
      - SOP Hub
      summary: Compare two versions
      description: |
        The server's diff of two versions: added, removed and modified steps
        (with each changed field), and the title and description when they differ.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      - name: from
        in: query
        required: true
        schema:
          type: integer
        description: Version number.
      - name: to
        in: query
        required: true
        schema:
          type: integer
        description: Version number.
      responses:
        '200':
          description: The comparison.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sop:
                    "$ref": "#/components/schemas/SopHubSopSummary"
                  from:
                    "$ref": "#/components/schemas/SopHubVersion"
                  to:
                    "$ref": "#/components/schemas/SopHubVersion"
                  stats:
                    type: object
                    properties:
                      total_changes:
                        type: integer
                      added_count:
                        type: integer
                      removed_count:
                        type: integer
                      modified_count:
                        type: integer
                      unchanged_count:
                        type: integer
                      step_count_change:
                        type: integer
                      change_percentage:
                        type: number
                  steps:
                    type: object
                    properties:
                      added:
                        type: array
                        items:
                          type: object
                          properties:
                            index:
                              type: integer
                            step:
                              "$ref": "#/components/schemas/SopHubStep"
                      removed:
                        type: array
                        items:
                          type: object
                          properties:
                            index:
                              type: integer
                            step:
                              "$ref": "#/components/schemas/SopHubStep"
                      modified:
                        type: array
                        items:
                          type: object
                          properties:
                            index:
                              type: integer
                            before:
                              "$ref": "#/components/schemas/SopHubStep"
                            after:
                              "$ref": "#/components/schemas/SopHubStep"
                            changes:
                              type: array
                              description: 'Each changed field, its label and its
                                old and new value as the step reads them (a hazard
                                as "Danger: Stored energy", a flag as Yes or No),
                                never stored JSON.'
                              items:
                                type: object
                                properties:
                                  field:
                                    type: string
                                  label:
                                    type: string
                                    description: What the field reads as, e.g. PPE
                                      for required_ppe.
                                  old:
                                    type: string
                                    nullable: true
                                  new:
                                    type: string
                                    nullable: true
                  text_changes:
                    type: array
                    items:
                      type: object
                      properties:
                        field:
                          type: string
                        before:
                          type: string
                          nullable: true
                        after:
                          type: string
                          nullable: true
        '404':
          description: The SOP or a version is not found (`version_not_found`).
        '422':
          description: Two versions are needed (`versions_required`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/completions":
    get:
      tags:
      - SOP Hub
      summary: Completions to review
      description: |
        Completions across the caller's SOPs, newest completion first on every
        `status` (a sign-off request by when it was submitted), rows never
        finished after them — as the web and /m/ lists order it. `status` filters to
        one of the web list's pills: `completed` (signed off where required),
        `pending_verification` (awaiting sign-off) or `not_completed` (not
        finished, or owed again) — together they partition the list.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: sop_id
        in: query
        required: false
        schema:
          type: integer
      - name: status
        in: query
        required: false
        schema:
          type: string
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: A person's name (first, last, preferred or full), as on the web
          list. Never matched against email.
      - name: completed_from
        in: query
        required: false
        schema:
          type: string
          format: date
        description: Completed on or after this day (YYYY-MM-DD); an unreadable date
          is ignored.
      - name: completed_to
        in: query
        required: false
        schema:
          type: string
          format: date
        description: Completed on or before this day (YYYY-MM-DD).
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of completions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SopHubReviewCompletion"
                  counts:
                    type: object
                    description: The status chips, counted before `status` narrows
                      the list (`sop_id` applies). total = completed + pending_verification
                      + not_completed.
                    properties:
                      total:
                        type: integer
                        description: All completions (the All chip).
                      completed:
                        type: integer
                      pending_verification:
                        type: integer
                      not_completed:
                        type: integer
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
        '404':
          description: "`sop_id` is not a SOP in the caller's reach."
  "/sop_hub/completions/export":
    get:
      tags:
      - SOP Hub
      summary: Export completions as CSV
      description: |
        The completions list as a CSV file, unpaginated — the web list's Export:
        the same filters as `GET /sop_hub/completions` (`sop_id`, `status`, `q`,
        `completed_from`, `completed_to`), one row per completion, with the
        columns Employee, Email, SOP, Status, Steps done, Completed at, Version
        completed, Signed off by, Signed off at, Read and understood at, Recorded
        through, Notes, Step notes and Sign-off note. The Email column is filled
        only where the People directory shows the caller email.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: sop_id
        in: query
        required: false
        schema:
          type: integer
      - name: status
        in: query
        required: false
        schema:
          type: string
      - name: q
        in: query
        required: false
        schema:
          type: string
      - name: completed_from
        in: query
        required: false
        schema:
          type: string
          format: date
      - name: completed_to
        in: query
        required: false
        schema:
          type: string
          format: date
      responses:
        '200':
          description: The CSV, sent as an attachment (sop-completions[-<sop>]-<date>.csv).
          content:
            text/csv:
              schema:
                type: string
                format: binary
        '403':
          description: Not an author
          or a missing scope.:
        '404':
          description: "`sop_id` is not a SOP in the caller's reach."
  "/sop_hub/completions/{id}/verify":
    post:
      tags:
      - SOP Hub
      summary: Sign a completion off
      description: |
        Records the caller's sign-off, tells the worker (when the tenant keeps
        "Confirm Sign-Off Back to the Employee" on) and writes the activity-log
        row — the web's own Verify. Refused with `cannot_verify`;
        `error.details.reason` says why (open set: own_completion,
        re_completion_required, already_verified, procedure_retired,
        sign_off_not_required, cannot_verify). 403 for your own completion or one
        owed again, 422 for the other states.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
      responses:
        '200':
          description: Signed off.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completion:
                    "$ref": "#/components/schemas/SopHubReviewCompletion"
                  message:
                    type: string
        '403':
          description: Not an author, or a sign-off the caller may not give.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
        '404':
          description: Not found
          or outside the caller's reach.:
        '422':
          description: The completion is in a state that cannot be signed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/completions/{id}/send_back":
    post:
      tags:
      - SOP Hub
      summary: Send a completion back to be redone
      description: |
        Re-opens a completion awaiting sign-off and tells the worker the reason
        (required, at most 1,000 characters: `send_back_reason_required`,
        `send_back_reason_too_long`). Other refusals are `cannot_send_back` with
        `error.details.reason` (open set).

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
                  maxLength: 1000
      responses:
        '200':
          description: Sent back.
          content:
            application/json:
              schema:
                type: object
                properties:
                  completion:
                    "$ref": "#/components/schemas/SopHubReviewCompletion"
                  message:
                    type: string
        '403':
          description: Not an author
          or the caller's own completion.:
        '404':
          description: Not found
          or outside the caller's reach.:
        '422':
          description: No reason, or the completion cannot be sent back.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/reports/compliance":
    get:
      tags:
      - SOP Hub
      summary: Compliance report
      description: |
        One row per published SOP that asks for completion, by title: its
        audience, signed-off, awaiting sign-off and pending counts, the rate
        (null when nobody is in the audience) and how many reminders a Remind
        would send now. `reminders` states the tenant's reminder rule.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: The report.
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        sop:
                          "$ref": "#/components/schemas/SopHubSopSummary"
                        target_users:
                          type: integer
                        completed:
                          type: integer
                        awaiting_sign_off:
                          type: integer
                        pending:
                          type: integer
                        completion_rate:
                          type: number
                          nullable: true
                        remindable:
                          type: integer
                        audience_warning:
                          type: string
                          nullable: true
                          description: Why the audience shrank — some or all of its
                            groups were deleted — in the caller's language; null when
                            none was.
                        audience_groups:
                          type: object
                          nullable: true
                          description: The numbers behind audience_warning, for a
                            client that words it itself; null when no group is gone.
                          properties:
                            missing:
                              type: integer
                              description: Groups in the audience that were deleted.
                            total:
                              type: integer
                              description: Groups the audience names.
                        last_reminded_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: The latest reminder sent on this SOP's current
                            completions (a re-opened completion starts its reminders
                            afresh).
                        reminded_today:
                          type: boolean
                          description: last_reminded_at falls on today in the business's
                            time zone (the "Reminded today" pill).
                        can_remind:
                          type: boolean
                  reminders:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      disabled_reason:
                        type: string
                        nullable: true
                        description: Why reminders are off, in the caller's language
                          and in words they can act on (turn them on in Settings,
                          or ask an HR admin or administrator); null while they are
                          on.
                      none_reason:
                        type: string
                        nullable: true
                        description: Why Send All Reminders has nobody to send to
                          while reminders are on (nobody who can be reminded owes
                          a SOP, or everyone who does was reminded in the last frequency_days
                          days or is at max_reminders), in the caller's language —
                          the line the web draws under its disabled button. Null when
                          reminders are off or someone can be reminded.
                      frequency_days:
                        type: integer
                      max_reminders:
                        type: integer
                      remindable_total:
                        type: integer
                      can_remind_all:
                        type: boolean
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
  "/sop_hub/sops/{id}/remind":
    post:
      tags:
      - SOP Hub
      summary: Remind everyone who owes a SOP
      description: |
        Queues reminders for the SOP's pending completions, held to the tenant's
        reminder frequency and the reminder limit. Refused with 422
        `reminders_disabled` while reminders are switched off.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/SopId"
      responses:
        '200':
          description: Queued.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubReminderDispatch"
        '404':
          description: Not found
          or outside the caller's reach.:
        '422':
          description: Reminders are switched off.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/reports/remind_all":
    post:
      tags:
      - SOP Hub
      summary: Remind every pending completion
      description: |
        Queues reminders for every pending completion the caller reviews (a
        scoped admin's: those in their reach), under the same throttle.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Queued.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubReminderDispatch"
        '422':
          description: Reminders are switched off.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/sop_hub/activity_logs":
    get:
      tags:
      - SOP Hub
      summary: SOP Hub activity log
      description: |
        The audit trail, newest first. `event_type` is an open set of strings;
        each row's `label` and the `event_types` list (the filter's options) are
        the web Activity Log's words.

        Authors only (403 `sop_hub_author_required`); a scoped admin sees the SOPs in their reach.

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: event_type
        in: query
        required: false
        schema:
          type: string
      - name: sop_id
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: A page of log rows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  activity_logs:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        event_type:
                          type: string
                          description: Open set.
                        label:
                          type: string
                          description: The event's name, in the caller's language.
                        description:
                          type: string
                        sop:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            title:
                              type: string
                        by:
                          type: string
                        created_at:
                          type: string
                          format: date-time
                  event_types:
                    type: array
                    description: Every event type the filter offers, with its label.
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                        label:
                          type: string
                  meta:
                    "$ref": "#/components/schemas/SopHubPageMeta"
  "/sop_hub/qr/{code}":
    get:
      tags:
      - SOP Hub
      summary: Resolve a scanned SOP label
      description: |
        The published SOP a label's code names, if the caller may read it, and
        whether they may start it (`reference_only` when it is not assigned to
        them). 403 `qr_access_disabled` while the tenant has QR access off; 422
        `sop_not_published` for a procedure that is not published (a draft, or withdrawn — the message says which).

        **Required Scopes:** `read:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: The SOP.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sop:
                    "$ref": "#/components/schemas/SopHubSopSummary"
                  my_completion:
                    allOf:
                    - "$ref": "#/components/schemas/SopHubCompletion"
                    nullable: true
                  capabilities:
                    type: object
                    properties:
                      can_complete:
                        type: boolean
                      reference_only:
                        type: boolean
        '404':
          description: No SOP the caller may read has this code.
  "/sop_hub/qr/{code}/start":
    post:
      tags:
      - SOP Hub
      summary: Start a SOP from its label
      description: |
        Creates or returns the caller's completion, recorded as a QR scan
        (`completion_method` is set by the server). Then work it through
        `/sop_hub/sops/{id}/steps/{step_position}/complete` and
        `/sop_hub/sops/{id}/complete`, which keep that method.

        **Required Scopes:** `write:sop_hub`
      security:
      - BearerAuth: []
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: The completion.
          content:
            application/json:
              schema:
                type: object
                properties:
                  sop_id:
                    type: integer
                  completion:
                    "$ref": "#/components/schemas/SopHubCompletion"
        '403':
          description: Not in the SOP's audience (`not_in_audience`)
          or QR access is off.:
        '422':
          description: Required training first (`training_prerequisite_required`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SopHubError"
  "/libraries/{id}":
    get:
      tags:
      - Libraries
      summary: Library detail — categories, items, view and ordering
      description: |
        One library with its categories and their items.

        **The header** (`library`) is the same card `/libraries/list` returns —
        same field names, same fallbacks — plus the settings and capability
        flags only the detail screen needs: `default_view_mode`, the two icon
        switches, and `can_manage_items` / `can_manage_library` /
        `can_reorder_categories`. A client can therefore draw the header from
        the row it already holds and fill in the rest.

        **Three header fields are manager-only and are ABSENT, not null, for
        everyone else**: `visibility`, `management_level` and `created_by`, all
        gated on `can_manage_library`. None of the three is in
        `LibraryDetailHeader.required` — read the flag, or check for the key,
        before binding a control to any of them. Each carries the full reason on
        its own entry below.

        **Each category** carries the `view` it renders in and the `sort_order`
        it sorts by — both admin-owned — plus `items_sorted_by` (the rule that
        actually applied, which differs from `sort_order` whenever `?sort=`
        overrides it) and `can_reorder_items`.

        **Each item** carries everything the row, the actions sheet and the
        details drawer read: the type and format labels, the tinted mark, the
        thumbnail, where it sits, its source state, both bylines, the file's
        format / size / dimensions / duration, the resolved destination with its
        `mode` and per-type description, the absolute copy link, the download,
        the caller's bookmark state, and `actions` — the kebab's option set,
        already gated, so a client renders the menu without re-implementing a
        single permission rule.

        **The management options in that menu are web hand-offs, not API
        verbs.** This namespace is a read API with two writes (the bookmark
        toggle and item delete) — there is no `PATCH`/`PUT`, no item-create and
        no reorder anywhere in `/api/v1/libraries`. So `actions.edit` and
        `actions.move` ship the item's `manage_url`, `can_reorder_items` ships
        the category's, and `can_reorder_categories` uses the library's `link`:
        each flag says whether to draw the row, and the URL says where the tap
        goes. Read `LibraryItemActions` before wiring the menu.

        **Empty categories are kept**, with `items: []`, so the response never
        hides the library's structure.

        ### Fields deliberately NOT carried

        The design mockup's item drawer shows a review state (`review`,
        `reviewEvery`), an expiry date, a version stack, comment and reaction
        counts, and a page count. `library_items` has no column behind any of
        them, so they are absent rather than fabricated — a made-up value on a
        governance surface is worse than a missing one. For the same reason the
        mockup's fourth sort, "Most viewed", is not accepted: nothing records a
        per-item view count. Passing `sort=views` falls back to `default`, and
        the echoed `sort` says so.
      parameters:
      - name: id
        in: path
        required: true
        description: Library id. Digit-constrained by the route.
        schema:
          type: integer
      - name: sort
        in: query
        required: false
        description: The reader's temporary page-wide order, applied to the category
          BLOCKS as well as to the items inside them. `default` (the default) means
          each category keeps its OWN `sort_order`; `az` and `recent` override every
          category. `updated` is accepted as an alias of `recent` for a client written
          against the mobile prototype, and the response always echoes the CANONICAL
          key. Any other value — including the prototype's unsupported `views` — falls
          back to `default`, exactly as the web dropdown does.
        schema:
          type: string
          enum:
          - default
          - az
          - recent
          - updated
          default: default
      - name: category_id
        in: query
        required: false
        description: |-
          Scope the response to ONE category — how a notification or a search hit deep-links into a single section. `meta.scoped_to_category_id` echoes it, and `meta.categories_count` / `meta.items_count` narrow with it so they always describe what was returned.
          `library.categories_count` / `library.items_count` do NOT narrow: they are the library's TRUE totals, identical to what `/libraries/list` returned for the same library. See those two fields for why the pair is split this way.
          An id that belongs to a DIFFERENT library (or to another tenant) returns an empty `categories` array rather than that library's items, and never 404s the library the caller legitimately asked for. A non-numeric value is ignored.
        schema:
          type: integer
      responses:
        '200':
          description: The library, its categories and their items
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/LibraryDetail"
        '401':
          description: Missing or invalid Bearer token
        '403':
          description: "`insufficient_permissions` — the token lacks `read:libraries`;
            or `access_denied` — the Libraries app is not accessible to this caller,
            either because the tenant is not entitled to it or because the caller
            is outside the app's per-group visibility rules."
        '404':
          description: No library this caller may access has that id. Returned identically
            for a library restricted to another audience, another tenant's library,
            and an id that does not exist — so the response cannot be used to prove
            a library exists. Error code `not_found`. A disabled library the caller
            is in the audience of but cannot manage answers the same status with the
            code `library_disabled` and the message "\"<library name>\" has been disabled
            and isn't available right now." — that caller already knows it exists.
  "/libraries/{library_id}/items/{id}":
    parameters:
    - name: library_id
      in: path
      required: true
      description: The library the item belongs to. The item is looked up THROUGH
        it, so this is not redundant — see the 404 notes on the operation.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The library ITEM id (not the library, and not the category).
      schema:
        type: integer
    delete:
      tags:
      - Libraries
      summary: Delete a library item
      description: |
        Permanently removes one item from a library — the native mirror of the
        web item kebab's ⋯ ▸ **Delete**
        (`Apps::Libraries::ItemsController#destroy`).

        **Authorization — whoever the web shows the Delete entry to, and nobody
        else.** Both surfaces ask the same predicate
        (`Libraries::Access#can_manage_items?`), so the API cannot admit someone
        the button hides from. That predicate is:

        | Library `management_level` | Who may delete an item |
        |---|---|
        | *(any)* | A Libraries **app admin** — which includes every business admin and super admin — always may. This short-circuit is evaluated **before** the level. |
        | `anyone` | Anyone who can **view** the library (all-users libraries: every member; specific-audience libraries: whoever an audience rule matches, at any role). |
        | `admins_and_specific` | A member matched by an audience rule whose role is **contributor** or **manager**. A `viewer`-role rule is not enough. |
        | `admins_only` | Admins only — the audience roster is ignored entirely, so a `manager`-role rule does **not** grant it. |
        | `domain_admins_only` | Super admins (plus the admin short-circuit above). |

        Audience rules match on any populated target — user, department, group,
        location, job family, job title, organizational role, or platform role —
        so a rule naming a *role* grants the same right as one naming a person.

        **What the delete removes** (the model owns the cascade, so this is
        byte-for-byte what the web's own `@item.destroy` leaves behind):

        * the library item row,
        * its uploaded file, if it had one (the ActiveStorage blob is purged),
        * its content-governance findings,
        * **every user's bookmark on it** — hard-deleted, not trashed. Those
          rows are user-visible on `/bookmarks` and count toward the saved-item
          total, so an orphan would inflate somebody's count forever.
          `bookmarks_deleted` reports how many went.

        The **library, the category and every sibling item are untouched** — an
        item is a leaf.

        **Not idempotent.** A second call returns `404 not_found`, because the
        item is genuinely gone. Clients should treat `404` on a retry as success.

        **Why the library id is in the path.** The item is resolved *through*
        the named library, exactly as the web's nested route does. Pairing a
        library you manage with an item id from one you do not returns `404`,
        never a delete. For the same reason an unknown library, a library in
        another tenant, and an item in another library are all `404` alike —
        none of them reveals that the id exists.

        **Order of checks.** The permission gate runs **before** the item
        lookup, so an unauthorized caller receives `403` whether or not the item
        exists and cannot probe for valid ids by reading `403` against `404`.

        Delete-level (owner decision 2026-10-04): requires the `destructive:libraries`
        scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Item deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - item_id
                - deleted
                - message
                properties:
                  item_id:
                    type: integer
                    description: The id of the item that was deleted.
                    example: 4821
                  title:
                    type: string
                    description: Its title, as it read at the moment of deletion.
                    example: Fire drill procedure
                  item_type:
                    type: string
                    description: The kind of item it was.
                    enum:
                    - simple_link
                    - file
                    - form
                    - survey
                    - wiki
                    - post
                    - image
                    - video
                    example: simple_link
                  library_id:
                    type: integer
                    description: The library it was removed from.
                    example: 12
                  library_name:
                    type: string
                    example: Employee Handbook
                  category_id:
                    type: integer
                    nullable: true
                    description: The category it sat in. Still present — the category
                      is not deleted with it.
                    example: 34
                  category_name:
                    type: string
                    nullable: true
                    example: Safety
                  bookmarks_deleted:
                    type: integer
                    description: How many users' bookmarks on this item were removed
                      by the cascade. Includes bookmarks already in the platform Trash.
                    example: 2
                  deleted:
                    type: boolean
                    description: Always true on a 200.
                    example: true
                  message:
                    type: string
                    example: Item removed from library.
                  unread_notification_count:
                    type: integer
                    description: Piggybacked on every response in this API for native
                      badge management. Unrelated to Libraries.
                    example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          description: Missing or invalid API token (`authentication_required` / `invalid_token`).
        '403':
          description: Missing the `write:libraries` scope (`insufficient_permissions`);
            the Libraries app is not accessible to the caller (`access_denied`); or
            the caller may not manage items in this library (`forbidden` — "You do
            not have permission to manage items in this library").
        '404':
          description: No such library in this business, no such item in that library,
            or the item is already gone (`not_found`). Cross-library and cross-tenant
            ids answer here, never 403. A disabled library the caller is in the audience
            of but cannot manage answers `library_disabled` ("\"<library name>\" has
            been disabled and isn't available right now.").
        '422':
          description: The delete was refused by the record itself (`delete_failed`).
            The message carries the model's own reason.
  "/libraries/{library_id}/items/{id}/captions":
    parameters:
    - name: library_id
      in: path
      required: true
      description: The library the item belongs to. The item is looked up THROUGH
        it.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The library ITEM id (a video).
      schema:
        type: integer
    get:
      tags:
      - Libraries
      summary: Get a video item's caption track
      description: |
        The WebVTT caption track of one VIDEO item, for the native player — the
        bearer-token twin of the web player's `<track>` route
        (`Apps::Libraries::CaptionsController#show`), which answers only a
        browser session. Every item card carries this URL as `captions_url`
        when its `captions` marker is `available`.

        **Authorization — the web player's, unchanged.** Anyone who can view
        the library (and, when it is disabled, only those who manage it). Needs
        the `read:libraries` scope. This is a READ on the same floor as
        `GET /libraries/{id}`, not one of the management writes above.
      operationId: getLibraryItemCaptions
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caption track.
          content:
            text/vtt:
              schema:
                type: string
                example: |-
                  WEBVTT

                  00:00:00.000 --> 00:00:03.000
                  Welcome to the brand guidelines.
        '401':
          description: Missing or invalid API token (`authentication_required` / `invalid_token`).
        '403':
          description: Missing the `read:libraries` scope (`insufficient_permissions`),
            or the Libraries app is not accessible to the caller (`access_denied`).
        '404':
          description: No such library you may view (`not_found`), no such item in
            that library (`not_found`), the item is not a video or has no caption
            track (`not_found` — "Library item <id> has no captions"), or a disabled
            library you cannot manage (`library_disabled`).
  "/wikis/dashboard":
    get:
      tags:
      - Wikis
      summary: Wikis dashboard
      description: |
        The native-client mirror of the web Wikis dashboard. Every number and
        list is produced by the same query object that backs the web page, so
        the two surfaces cannot drift.

        **The response is persona-aware.** `is_admin` tells the client which
        shape it received:

        * **Every viewer** gets the `my_wikis`, `bookmarked`, `mentions` and
          `total_views` KPIs plus `my_drafts`, `my_bookmarked_wikis` and
          `recently_updated`. A DEPRECATED `stats.pinned` KPI and a DEPRECATED
          `my_pinned_wikis` list mirror `stats.bookmarked` and
          `my_bookmarked_wikis` exactly, for native builds shipped before the
          pin→bookmark rename. Read the `bookmarked` keys; ignore the `pinned`
          ones. `total_views.this_month` is scoped per persona —
          for a non-admin it counts views **this month on the pages that viewer
          created** — and a non-admin payload additionally carries
          `total_views.total` (**all-time** views on those same pages, the
          figure the web dashboard's "Total Views" card renders) and
          `recently_viewed`.
        * **Admin-tier viewers** (business admin or above) additionally get the
          `total_wikis` (including archived) and `stale_wikis` KPIs, a
          `total_views.this_month` figure, the configured `stale_days` window,
          and the `most_viewed_wikis`, `top_contributors`, `recent_activity`
          and `needs_attention` sections. Admin-only keys are **absent** (not
          null) for non-admins.

        Counts and lists are visibility-scoped: a non-admin never sees drafts
        or group-restricted wikis they could not open. Every list returns at
        most 5 rows.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    required:
                    - is_admin
                    - stats
                    - my_drafts
                    - my_bookmarked_wikis
                    - recently_updated
                    properties:
                      is_admin:
                        type: boolean
                        description: True when the caller is a business admin or above.
                          Determines whether the admin-only keys below are present.
                        example: true
                      stale_days:
                        type: integer
                        description: Admin only. The configured stale window in days
                          — a published wiki not updated within it counts toward `stale_wikis`.
                        example: 120
                      stats:
                        type: object
                        properties:
                          my_wikis:
                            type: object
                            description: Pages the caller authored.
                            properties:
                              total:
                                type: integer
                                example: 12
                              published:
                                type: integer
                                example: 9
                              drafts:
                                type: integer
                                example: 3
                          bookmarked:
                            type: object
                            description: Wikis the caller bookmarked.
                            properties:
                              total:
                                type: integer
                                example: 4
                          pinned:
                            type: object
                            deprecated: true
                            description: DEPRECATED mirror of `bookmarked` for pre-rename
                              native builds. Always equal to it.
                            properties:
                              total:
                                type: integer
                                example: 4
                          mentions:
                            type: object
                            description: Wikis whose comments @mention the caller.
                            properties:
                              total:
                                type: integer
                                example: 2
                          total_views:
                            type: object
                            description: 'Page views. The SCOPE of `this_month` is
                              persona-dependent: org-wide for admin-tier viewers,
                              and limited to the pages the caller CREATED for everyone
                              else. Read `is_admin` to know which you received. `total`
                              is the ALL-TIME view sum on the pages the caller created
                              and is present for non-admins only — there is no org-wide
                              equivalent behind the admin reading.'
                            properties:
                              this_month:
                                type: integer
                                example: 348
                              total:
                                type: integer
                                example: 1290
                          total_wikis:
                            type: object
                            description: Admin only. Org-wide totals by status.
                            properties:
                              total:
                                type: integer
                                example: 86
                              published:
                                type: integer
                                example: 70
                              drafts:
                                type: integer
                                example: 11
                              archived:
                                type: integer
                                example: 5
                          stale_wikis:
                            type: object
                            description: Admin only. Published wikis older than `stale_days`.
                            properties:
                              total:
                                type: integer
                                example: 6
                      my_drafts:
                        type: array
                        description: The caller's unpublished drafts, most recently
                          edited first (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              last_edited_at:
                                type: string
                                format: date-time
                      my_bookmarked_wikis:
                        type: array
                        description: The caller's bookmarked wikis, most recently
                          bookmarked first (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 128
                      my_pinned_wikis:
                        type: array
                        deprecated: true
                        description: DEPRECATED mirror of `my_bookmarked_wikis` —
                          the same cards, for native builds shipped before the pin→bookmark
                          rename. Read `my_bookmarked_wikis`.
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 128
                      recently_updated:
                        type: array
                        description: Published wikis with the latest edits (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              last_updated_at:
                                type: string
                                format: date-time
                      recently_viewed:
                        type: array
                        description: Non-admin only. Wikis the caller opened most
                          recently (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 128
                              viewed_at:
                                type: string
                                format: date-time
                      most_viewed_wikis:
                        type: array
                        description: Admin only. Published wikis ranked by all-time
                          views (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              views_count:
                                type: integer
                                example: 1042
                      top_contributors:
                        type: array
                        description: Admin only. Authors ranked by pages created,
                          with the summed views of those pages (max 5).
                        items:
                          type: object
                          properties:
                            user_id:
                              type: integer
                              example: 42
                            user_name:
                              type: string
                              example: Dana Lee
                            user_image:
                              type: string
                              nullable: true
                              description: Absolute avatar URL, or null when unavailable.
                            wikis_created:
                              type: integer
                              example: 14
                            total_views:
                              type: integer
                              example: 903
                      recent_activity:
                        type: array
                        description: Admin only. Latest edit/publish/restore events
                          across the business (max 5).
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 991
                            actor_name:
                              type: string
                              example: Dana Lee
                            actor_image:
                              type: string
                              nullable: true
                              description: Absolute avatar URL, or null when unavailable.
                            change_type:
                              type: string
                              description: Raw event type (WikiVersion#change_type).
                              enum:
                              - created
                              - updated
                              - minor_edit
                              - major_revision
                              - restored
                              - published
                              - ownership_changed
                              example: published
                            verb:
                              type: string
                              description: 'Display verb for the event, from `wikis_activity_verb`:
                                "created", "published", "restored", "revised" (major_revision),
                                "made a minor edit to" (minor_edit), "transferred
                                management of" (ownership_changed), or "updated" for
                                anything else.'
                              example: published
                            transferred_to:
                              type: string
                              nullable: true
                              description: The new manager's name — present only for
                                a management transfer, null otherwise.
                            wiki_id:
                              type: integer
                              nullable: true
                              example: 17
                            wiki_title:
                              type: string
                              nullable: true
                              example: Shift Swap Guidelines
                            wiki_icon:
                              type: string
                              example: fas fa-book
                            wiki_color:
                              type: string
                              example: "#3b7ddd"
                            occurred_at:
                              type: string
                              format: date-time
                      needs_attention:
                        type: array
                        description: Admin only. Drafts pending publish across the
                          business (max 5).
                        items:
                          allOf:
                          - "$ref": "#/components/schemas/WikiDashboardRow"
                          - type: object
                            properties:
                              creator_name:
                                type: string
                                nullable: true
                                example: Dana Lee
                              last_updated_at:
                                type: string
                                format: date-time
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`), or the token lacks `read:wikis` (error code
            `insufficient_permissions`).
  "/wikis/list":
    get:
      tags:
      - Wikis
      summary: Wikis list ("All Wikis")
      description: |
        The native-client mirror of the web "All Wikis" browse screen. Returns a
        paginated, filtered, ordered list of wikis. Visibility is scoped exactly
        like the web: a non-admin never sees another user's drafts or
        group-restricted pages.

        **Every filter returns a FLAT list** — no row ever carries a `sub_wikis`
        key. Each row is a card (title/color/icon) plus a `has_sub_wikis` boolean
        (does this page have at least one visible published child) so a client can
        show an expand affordance and fetch the children lazily via
        `GET /wikis/{id}/sub_wikis`.

        * `all` lists published ROOT wikis only (parent_id IS NULL). Its
          `filter_counts["all"]` badge counts the WHOLE published set — roots AND
          their sub-wikis — so it reads as the true "total wikis", even though the
          list itself shows only roots. `meta.total_count` stays the ROOT count so
          pagination over the list is correct.
        * Every OTHER filter is a flat list of matching wikis at any depth; its
          count == its list total.

        **Filters** (`filter`) are persona-aware. `filter_counts` carries the
        total per available filter:

        * **Every viewer:** `all` (default — published top-level roots), `draft`
          (a member sees only their own), `owned` ("Mine" — EVERYTHING the caller
          created, sub-pages and drafts included, matching the web browse and the
          dashboard's `my_wikis.total`), `bookmarked`, `mentions` (pages where
          someone @mentioned the caller that they have not opened since — the web
          and phone Mentions filter), and `archived` (an admin sees
          every archived page in the business; a member sees only the ones they
          CREATED — the same admin/member split `draft` uses).
        * **Admin-tier viewers** additionally get `stale` (published past the
          freshness window). A member who requests that admin-only filter is
          served the `all` list (and `active_filter` echoes `all`).

        **Ordering** (`sort`): `position` (default — the admin-set Custom Order,
        i.e. the reorder target), `recent` (most recently updated), `title` (A–Z).
      security:
      - BearerAuth: []
      parameters:
      - name: filter
        in: query
        required: false
        description: |-
          all (default) | draft | owned | bookmarked | mentions | stale | archived. `mentions` lists the pages where someone @mentioned the caller that they have not opened since. `stale` is admin-only. `archived` is available to every persona but role-scopes its contents (admin: the whole business; member: only the pages they created). An unknown or forbidden value falls back to `all`.
          `pinned` is a DEPRECATED alias of `bookmarked`, kept for native builds shipped before the rename; it selects the identical wikis and is echoed back verbatim in `active_filter` so a pre-rename client can still match it against its own chip. New integrations must send `bookmarked`.
        schema:
          type: string
          enum:
          - all
          - draft
          - owned
          - bookmarked
          - pinned
          - mentions
          - stale
          - archived
          default: all
      - name: sort
        in: query
        required: false
        description: 'position (the admin-set Custom Order) | recent | title | popular
          | created. The DEFAULT is conditional, matching the web rail: `position`
          when `filter=all`, `recent` for every other filter. An unrecognised value
          falls back to `position`.'
        schema:
          type: string
          enum:
          - position
          - recent
          - title
          - popular
          - created
      - name: group_id
        in: query
        required: false
        description: |-
          Restrict the list to the wikis SHARED WITH ONE group — a `NotificationRecipientGroup` id, the same ids `GET /wikis/{id}` returns in its `groups` array. ORTHOGONAL to `filter`: the two compose, so `?filter=draft&group_id=7` is "drafts inside group 7".
          "In the group" means EXPLICITLY shared with it (the wiki's read visibility is restricted to that group). A wiki visible to *everyone* is readable by the group's members but is **not** in the group, so it is excluded — otherwise the filter would return nearly the whole tenant.
          The filter only ever NARROWS: read visibility still applies, so a caller naming a group whose wikis they cannot read gets an empty list rather than a peek inside it. A group id from another business likewise matches nothing.
          A well-formed but unknown id is APPLIED (empty list, and `group_id` echoed) rather than ignored — silently returning the unfiltered list after a caller asked for one group would be misleading. A blank, non-numeric or non-positive value IS ignored, and the response echoes `group_id: null` so the two cases are distinguishable.
        schema:
          type: integer
          minimum: 1
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Wikis list retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wikis
                - active_filter
                - sort
                - filter_counts
                - meta
                properties:
                  active_filter:
                    type: string
                    description: The filter actually applied (may differ from the
                      request when a member asked for an admin-only filter).
                    example: all
                  sort:
                    type: string
                    example: position
                  group_id:
                    type: integer
                    nullable: true
                    description: The group filter actually applied, or `null` when
                      none was (a blank/non-numeric/non-positive `group_id` is ignored).
                      Lets a client tell "filtered to a group and it's empty" apart
                      from "my group_id was ignored".
                    example: 7
                  filter_counts:
                    type: object
                    description: |-
                      Total per available filter for this caller. Always carries all/draft/owned/bookmarked/mentions/archived; admin-tier callers also get `stale`. Every count equals its filter's list total EXCEPT `all`, which counts roots + sub-wikis (see below) while the `all` list shows only roots.
                      A DEPRECATED `pinned` key mirrors `bookmarked` and is emitted immediately after it, so a native build shipped before the rename still renders its chip row in the original order. Read `bookmarked`; ignore `pinned`.
                      `archived` is present for EVERY persona, but a member's count covers only the archived pages they created, while an admin's covers every archived page in the business.
                      When `group_id` is applied these counts are narrowed to that group too, so each chip keeps counting exactly what clicking it returns inside the group.
                    properties:
                      all:
                        type: integer
                        description: Roots + sub-wikis (the whole published set) —
                          the `all` list shows only roots, so this is >= meta.total_count.
                        example: 15
                      draft:
                        type: integer
                        example: 3
                      owned:
                        type: integer
                        example: 5
                      bookmarked:
                        type: integer
                        example: 2
                      pinned:
                        type: integer
                        example: 2
                        deprecated: true
                        description: Mirror of `bookmarked` for pre-rename native
                          builds. Always equal to it.
                      mentions:
                        type: integer
                        example: 1
                        description: Every persona. Pages where someone @mentioned
                          the caller that they have not opened since.
                      stale:
                        type: integer
                        example: 1
                        description: Admin-tier only.
                      archived:
                        type: integer
                        example: 4
                        description: 'Every persona. Admin: every archived page in
                          the business; member: only the ones they created.'
                  wikis:
                    type: array
                    description: A FLAT list of cards — one per matching wiki. For
                      `filter=all` these are published ROOT wikis only. No row carries
                      a `sub_wikis` key; each carries `has_sub_wikis` and children
                      are fetched via `GET /wikis/{id}/sub_wikis`.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/WikiCard"
                      - type: object
                        required:
                        - has_sub_wikis
                        properties:
                          has_sub_wikis:
                            type: boolean
                            description: True when this page has at least one visible
                              published child.
                            example: true
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Rows matching the filter. For `all` this is the
                          ROOT count (the paginated list is root-only); the roots
                          + sub-wikis grand total is `filter_counts.all`.
                        example: 10
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`), or the token lacks `read:wikis` (error code
            `insufficient_permissions`).
  "/wikis/search":
    get:
      tags:
      - Wikis
      summary: Search wikis (by text and/or tags)
      description: |
        Search across the knowledge base — the native mirror of the web Wikis
        search screen PLUS the browse rail's tag chips, in one endpoint. Both
        surfaces run the SAME shared scope, so they return the same hits in the
        same order.

        **Text search (`q`)** matches `title`, `description` and `body` with a
        case-insensitive substring match. The term is LIKE-escaped, so a literal
        `%` or `_` the user types matches literally instead of acting as a
        wildcard.

        **Tag search (`tags`)** filters by tag NAME, case-insensitively, against
        this business's tag vocabulary — the same tags returned in every hit's
        `tags` array and by `GET /api/v1/wikis/{id}`. Accepts a comma-separated
        list (`?tags=hr,policy`) or the repeated form
        (`?tags[]=hr&tags[]=policy`), up to 10 names; `tag` is accepted as an
        alias. With several tags, `match=any` (the default) returns wikis carrying
        **at least one** of them and `match=all` returns only wikis carrying
        **every** one.

        The two dimensions are independent and compose with AND:

        | Request | Result |
        |---|---|
        | `?q=zookeeper` | text search |
        | `?tags=runbook` | browse-by-tag — a real search, **not** the blank-query no-op |
        | `?q=zookeeper&tags=runbook` | the term, narrowed to pages tagged `runbook` |
        | neither | empty result set, 200 |

        A tag name this business does not have is never silently ignored — a
        filter that evaporated would hand the caller wikis that miss the tag they
        just narrowed to. With `match=any` the other, known names still apply
        ("tagged with at least one of them" is still satisfiable); with
        `match=all`, or when EVERY requested name is unknown, the result
        **narrows to nothing**, because no page can carry a tag this business does
        not have. The response echoes `tags` (the canonical names actually
        applied), `unknown_tags` (names that do not exist here) and `match`, so a
        client can tell "filtered and empty" apart from "my tag was ignored".

        **Visibility** mirrors the web on both dimensions: an admin-tier caller
        searches every status (drafts and archived included); everyone else
        searches published wikis plus their own, intersected with the app's
        read-visibility rules — so a member never finds another user's draft or a
        group-restricted page, tagged or not.

        A **blank or missing `q` with no `tags` returns an empty result set with
        200**, not an error, so a client may call this on every keystroke. Results
        are ordered newest-updated first.

        Each real search that carries a **term** is recorded for search analytics.
        A tag-only browse is not logged — those reports exist to surface gaps in
        what people typed.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        description: Search term. Blank/omitted returns an empty result set (200)
          unless `tags` is supplied.
        schema:
          type: string
          example: zookeeper
      - name: tags
        in: query
        required: false
        description: 'Tag filter. Comma-separated (`hr,policy`) or repeated (`?tags[]=hr&tags[]=policy`).
          Matched on tag name, case-insensitively; blanks and duplicates are dropped
          and at most 10 names are considered. A name this business does not have
          is reported in `unknown_tags`: with `match=any` the remaining known names
          still apply, and with `match=all` — or when every name is unknown — the
          result set narrows to nothing rather than widening to the names that do
          exist.'
        schema:
          type: string
          example: hr,policy
      - name: tag
        in: query
        required: false
        description: Alias for `tags`, matching the web browse rail's `?tag=` chip
          link. Ignored when `tags` is present and non-blank.
        schema:
          type: string
          example: hr
      - name: match
        in: query
        required: false
        description: How several tags combine — `any` (tagged with at least one) or
          `all` (tagged with every one). Ignored when no tags are supplied; an unrecognised
          value falls back to `any`.
        schema:
          type: string
          enum:
          - any
          - all
          default: any
      - name: page
        in: query
        required: false
        description: 1-based page number.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Hits per page, 1–50.
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Search executed successfully (may contain zero hits)
          content:
            application/json:
              schema:
                type: object
                required:
                - query
                - tags
                - unknown_tags
                - match
                - wikis
                - meta
                properties:
                  query:
                    type: string
                    description: The stripped term the server searched for (echoed
                      back).
                    example: zookeeper
                  tags:
                    type: array
                    description: The tag filter as APPLIED — canonical (stored-case)
                      tag names, de-duplicated and capped at 10. Empty when no tag
                      filter was requested, and also empty when every requested name
                      was unknown (in which case the result set is empty and the names
                      appear in `unknown_tags`). Not to be confused with each hit's
                      own `tags`.
                    items:
                      type: string
                    example:
                    - hr
                    - policy
                  unknown_tags:
                    type: array
                    description: Requested tag names with no matching tag in this
                      business (a typo, or a tag from another tenant). With `match=all`,
                      or when every requested name was unknown, their presence means
                      the result set was narrowed to nothing; with `match=any` the
                      known names still applied.
                    items:
                      type: string
                    example:
                    - hr-polices
                  match:
                    type: string
                    enum:
                    - any
                    - all
                    description: The multi-tag combination rule actually applied (an
                      unrecognised `match` falls back to `any`).
                    example: any
                  wikis:
                    type: array
                    description: Matching wikis, newest-updated first.
                    items:
                      "$ref": "#/components/schemas/WikiSearchHit"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        example: 7
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`), or the token lacks `read:wikis` (error code
            `insufficient_permissions`).
  "/wikis/trash":
    get:
      tags:
      - Wikis
      summary: Trash — the caller's deleted wikis
      description: |
        The soft-deleted wikis this caller may see, most recently deleted first —
        the native mirror of the web Trash screen (`Apps::WikisController#trash`).
        `DELETE /wikis/{id}` is a soft delete, and this endpoint plus
        `POST /wikis/{id}/restore` are how a deleted page comes back.

        A wiki is listed here as its own row even when it was trashed as part of a
        parent's cascade, exactly as the web Trash table lists it — so a sub-wiki
        can be restored on its own (see the restore endpoint's
        `parent_still_trashed`).

        * **Authorization — who sees which rows**, mirroring the web screen's
          three arms:
          * a business **admin (or above)** sees every trashed wiki in the
            business;
          * a **delegated Wikis app admin** (Admin › Apps › App Admins) sees
            published trashed wikis plus their own, intersected with the ordinary
            read-visibility rule — that grant carries no read access to
            group-restricted pages, and an unnarrowed list would disclose another
            member's deleted draft title and author;
          * **everyone else** sees only the wikis **they created**. This is the
            same `created_by_id` anchor `DELETE /wikis/{id}` authorizes on, so it
            returns strictly less than the caller was already permitted to delete
            — a member who deletes a page tree they own can find and recover it
            without an admin.
        * There is no "empty" error: a caller with nothing in the Trash gets `200`
          with `wikis: []`.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Trash retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wikis
                - meta
                properties:
                  wikis:
                    type: array
                    description: Trashed wikis, most recently deleted first (`id`
                      descending breaks the tie between pages trashed together by
                      one cascade, so paging over them is stable).
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/WikiCard"
                      - type: object
                        required:
                        - status
                        - discarded_at
                        - creator
                        properties:
                          status:
                            type: string
                            description: The status the wiki had when it was deleted.
                            enum:
                            - draft
                            - published
                            - archived
                            example: published
                          discarded_at:
                            type: string
                            format: date-time
                            description: When the wiki was moved to Trash (ISO-8601).
                            example: '2026-09-05T11:42:07Z'
                          deleted_by:
                            type: string
                            nullable: true
                            description: 'Who moved it to Trash (the web Trash table''s
                              "Deleted by"): the deleter''s name, `with “<parent title>”`
                              for a sub-wiki that went with its parent on this page
                              of results, or null when it cannot be told.'
                            example: Dana Lee
                          creator:
                            type: object
                            description: The person who manages the wiki (the web
                              Trash table's "Managed by").
                            required:
                            - id
                            - name
                            properties:
                              id:
                                type: integer
                                nullable: true
                                example: 1
                              name:
                                type: string
                                example: Dana Lee
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Trashed wikis visible to this caller.
                        example: 5
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`), or the token lacks `read:wikis` (error code
            `insufficient_permissions`).
  "/wikis/{id}/restore":
    parameters:
    - name: id
      in: path
      required: true
      description: The trashed wiki's id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Restore a wiki from Trash
      description: |
        Recovers a soft-deleted wiki — the inverse of `DELETE /wikis/{id}` and the
        native mirror of the web "Restore" button
        (`Apps::Wikis::PagesController#restore` → `Wiki#undiscard!`).

        **The restore is recursive**: it also brings back every sub-wiki that the
        delete cascaded, and `sub_wikis_restored` counts exactly those. A sub-wiki
        that was deleted **separately, earlier** keeps its own deletion timestamp
        and deliberately stays in the Trash — it is not counted and does not come
        back, so restoring a parent never resurrects a page the user deleted on
        purpose.

        Distinct from `DELETE /wikis/{id}/archive`: archiving is a **status**, and
        an archived wiki was never deleted. This endpoint is only for wikis in the
        Trash.

        * **Authorization** — a **Wikis app admin** (a business admin-or-above, or
          a delegated app admin) may restore any trashed wiki; **everyone else may
          restore only the wikis they created**, which is the same `created_by_id`
          anchor `DELETE /wikis/{id}` authorizes on. Deliberately not widened to
          "anyone who could read the page": a member who could merely view a
          published wiki never had the right to delete it, so they have nothing to
          undo. A wiki outside the caller's restore rights returns `404`, never
          revealing that it exists.
        * `parent_still_trashed` is `true` when this page's own parent is still in
          the Trash. The page is live again but has no visible place in the tree
          until that parent is restored too — surface it, or the user hunts for a
          wiki that "came back" and then isn't anywhere. `message` already says so
          in prose.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki restored from Trash
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - restored
                - sub_wikis_restored
                - parent_still_trashed
                - message
                properties:
                  wiki_id:
                    type: integer
                    description: The id of the restored wiki.
                    example: 2633
                  restored:
                    type: boolean
                    description: Always true on a 200 (the wiki left the Trash).
                    example: true
                  sub_wikis_restored:
                    type: integer
                    description: How many descendant sub-wikis came back with it —
                      the ones this page's own delete cascaded. Excludes descendants
                      that were trashed separately and stay in the Trash.
                    example: 2
                  parent_still_trashed:
                    type: boolean
                    description: True when this wiki's parent is still in the Trash,
                      so the restored page won't appear in the tree until that parent
                      is restored too. Always false for a top-level wiki.
                    example: false
                  message:
                    type: string
                    description: The web's own restore wording, safe to show verbatim.
                    example: Restored "Dental". 2 sub-wikis restored with it.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No trashed wiki with that id is restorable by this caller —
            missing, another tenant's, or created by someone else and the caller is
            not a Wikis app admin (error code `not_found`).
        '409':
          description: The wiki exists and the caller may restore it, but it is **not
            in the Trash** — it was never deleted, or a duplicate/retried call already
            restored it (error code `not_discarded`). Nothing changed; treat it as
            already restored.
  "/wikis/{id}/sub_wikis":
    parameters:
    - name: id
      in: path
      required: true
      description: The parent wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: Sub-wikis of a wiki (one level)
      description: |
        Returns the immediate (ONE level) **published** sub-wikis of the given
        wiki — not the whole subtree. Each child carries its title / icon / color
        plus `has_sub_wikis` (whether that child has published children of its
        own, so a client can render an expand affordance without a second request
        per row).

        Visibility is scoped exactly like `GET /api/v1/wikis/list`: a non-admin
        never sees another user's drafts or group-restricted pages. The parent
        wiki is resolved within that visible scope, so an id the caller cannot see
        (restricted, archived-away, cross-tenant, or non-existent) returns `404`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Sub-wikis retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - sub_wikis
                properties:
                  wiki_id:
                    type: integer
                    description: The parent wiki id (echoes the path id).
                    example: 42
                  sub_wikis:
                    type: array
                    description: Immediate published children, ordered like the web
                      tree (position, then title).
                    items:
                      type: object
                      required:
                      - id
                      - title
                      - icon
                      - color
                      - bookmarked
                      - has_sub_wikis
                      properties:
                        id:
                          type: integer
                          example: 57
                        title:
                          type: string
                          example: Onboarding
                        icon:
                          type: string
                          description: FontAwesome class; falls back to `fas fa-book`
                            when unset.
                          example: fas fa-door-open
                        color:
                          type: string
                          description: Whitelist-coerced safe hex; falls back to `#6c757d`.
                          example: "#2E7D32"
                        bookmarked:
                          type: boolean
                          description: Whether the calling user has bookmarked this
                            child wiki.
                          example: false
                        has_sub_wikis:
                          type: boolean
                          description: True when this child has published children
                            of its own.
                          example: true
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller — not enabled
            for the business, or the caller is outside the app's visibility audience
            (error code `access_denied`), or the token lacks `read:wikis` (error code
            `insufficient_permissions`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/viewers":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: People who viewed a wiki
      description: |
        Returns the distinct users who viewed the wiki, ordered by **view_count
        DESC** (most views first), with **most-recently-viewed** as the
        tiebreaker on an equal count. Aggregated per user with a real
        `view_count` and `last_viewed_at`.

        Mirrors the web "Viewed by N people" modal:
        * **Authorization** — admin-tier OR the wiki's creator only. A plain
          member viewing someone else's page must NOT learn who read it; they
          get `403 access_denied`.
        * **Search** — `search` filters by viewer name (ILIKE), like the modal's
          "Search people…" box. A `%`/`_` typed by the user matches literally.

        A view is deduplicated to at most one event per user per 30 minutes, so
        `view_count` is the number of distinct view sessions (not raw page loads).
        The wiki is resolved within the caller's visible scope, so an id they
        cannot see returns `404`.
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Filter viewers by name (case-insensitive substring).
        schema:
          type: string
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Viewers retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - viewers
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  viewers:
                    type: array
                    description: Distinct viewers, ordered by view_count DESC then
                      last_viewed_at DESC.
                    items:
                      type: object
                      required:
                      - user_id
                      - name
                      - image_url
                      - job_title
                      - view_count
                      - last_viewed_at
                      properties:
                        user_id:
                          type: integer
                          example: 1884
                        name:
                          type: string
                          example: Georgia Fitzpatrick
                        image_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (null if unavailable).
                          example: https://officechat.workforce.mangoapps.com/avatars/1884.png
                        job_title:
                          type: string
                          nullable: true
                          description: The viewer's job title in this business (null
                            if unset).
                          example: Data Coordinator
                        view_count:
                          type: integer
                          description: Number of distinct view sessions by this user
                            (30-min dedup).
                          example: 3
                        last_viewed_at:
                          type: string
                          format: date-time
                          description: When this user most recently viewed the wiki
                            (ISO-8601).
                          example: '2026-07-30T05:29:11Z'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Distinct viewers in the named list (honors search;
                          people a works council hides are counted in names_hidden
                          instead).
                        example: 12
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  names_hidden:
                    type: object
                    description: |
                      Present only when a works council hides names (General
                      Settings › Countries): the viewers left out of `viewers`
                      — counted, never named.
                      While the hidden group is under the company's smallest reportable
                      group, `count` and `group_count` are null and `fewer_than` is that
                      group, "a few" (the status counts beside it count only the named
                      people) — the shape of `BroadcastNamesHidden`.
                    properties:
                      count:
                        type: integer
                        nullable: true
                      group_count:
                        type: integer
                        nullable: true
                        description: 'Null since #4546: a hidden group under the company''s
                          smallest group is "a few" (`fewer_than`). Kept for older
                          clients.'
                      fewer_than:
                        type: integer
                        description: 'Present only when the people it leaves out (or
                          the hidden group) are fewer than the company''s smallest
                          group: that number — they are "a few", and `count` / `group_count`
                          are null (#4546).'
                      reason:
                        type: string
                        example: Names are hidden for people in Germany (works council).
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller, OR the caller
            is neither an admin nor the wiki's creator (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/comments":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: Comment thread on a wiki
      description: |
        The wiki's comment thread, modelled on the feeds comment API. Returns a
        paginated list of TOP-LEVEL comments, each inlining its direct replies
        (threading is ONE level deep — a reply always has `replies: []`). Each
        comment carries its author, body (with raw `@[Name](mention:id)` tokens
        preserved + a deduped `mentioned_user_ids`), a reactions summary, and its
        file attachments.

        Soft-deleted comments are omitted entirely (they never appear in the
        thread or the count). `meta.total_count` counts TOP-LEVEL comments only
        (replies aren't counted). Ordering is `created_at ASC` (oldest first) for
        both comments and their replies. The wiki is resolved within the caller's
        visible scope, so an id they can't see returns `404`.
      security:
      - BearerAuth: []
      parameters:
      - name: parent_comment_id
        in: query
        required: false
        description: When present, returns the direct REPLIES of that comment instead
          of the top-level thread. Resolved through this wiki's own comments, so a
          parent id from another wiki's thread returns an empty list.
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: Comment thread retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - comments
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  comments:
                    type: array
                    description: Top-level comments (oldest first), each with inlined
                      one-level replies.
                    items:
                      "$ref": "#/components/schemas/WikiComment"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 10
                      total_count:
                        type: integer
                        description: Top-level comment count (excludes replies).
                        example: 24
                      total_pages:
                        type: integer
                        example: 3
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
    post:
      tags:
      - Wikis
      summary: Add a comment or reply to a wiki
      description: |
        Posts a comment on the wiki, or a reply to an existing top-level comment.
        Requires comment permission on the wiki
        (published, comments enabled, and the caller allowed by the wiki's
        `comment_permission`; otherwise 403 `comments_disabled`).

        **Attachments** — send files as multipart `attachments[]` (images shown
        as thumbnails, other files as download chips). Up to 5 files, 10 MB each.

        **Mentions** — same `@[Name](mention:id)` tokens the feeds/web composer
        emit: include them inline in `body` and the server parses them into the
        comment's `mentioned_user_ids` and notifies each mentioned user who
        can read the wiki. Mentioned people who cannot read it are not
        notified; the response lists them in `mentions_not_notified` so the
        client can tell the author.

        **Threading is ONE level.** `parent_comment_id` must reference a
        TOP-LEVEL comment of THIS wiki; replying to a reply returns 422
        `reply_depth_exceeded`, and a `parent_comment_id` from another wiki
        returns 422 `parent_not_found`.

        On success returns the created comment in the same shape as the thread
        list rows (`{ comment: WikiComment }`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  description: Comment text; may contain `@[Name](mention:id)` mention
                    tokens. Max 2000 chars.
                  example: Great page @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
                  description: Top-level comment id to reply to. Omit for a new top-level
                    comment.
                attachments:
                  type: array
                  description: Up to 5 files, 10 MB each (PNG/JPG/WEBP/GIF/PDF/TXT/CSV/ZIP/Word/Excel).
                  items:
                    type: string
                    format: binary
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  example: Great page @[Casey Poster](mention:49290)
                parent_comment_id:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/WikiComment"
                  mentions_not_notified:
                    type: array
                    description: Mentioned people who were not notified because they
                      cannot read the wiki (empty when everyone mentioned can read
                      it, while the tenant has mention notifications switched off,
                      or if sending them failed).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  mention_notices_off:
                    type: boolean
                    description: True when the comment @mentions someone besides its
                      author while the tenant has "Notify Users on @Mention" switched
                      off in Wikis Settings — nobody was told they were mentioned
                      (`mentions_not_notified` is then empty). The web and phone readers
                      show the author a warning.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible, or the wiki does not permit
            the caller to comment (error code `comments_disabled`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '422':
          description: Validation failed (e.g. blank body), or an invalid `parent_comment_id`
            — `parent_not_found` (another wiki's comment) or `reply_depth_exceeded`
            (replying to a reply).
  "/wikis/{id}/reactors":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: People who reacted to a wiki (+ per-emoji counts)
      description: "Returns the users who reacted to the wiki, **most-recently-reacted\nfirst**,
        aggregated **per user** — one row per person carrying every\nemoji they left
        — plus the wiki's per-emoji `reaction_counts`.\n\nMirrors the web \"who reacted\"
        sheet (`Platform::ReactorsController`,\nopened from the reaction bar's summary),
        with two improvements: the web\nreturns one row *per reaction* (a person who
        left \U0001F44D and ❤️ appears\ntwice) and caps at a flat 200 rows; this endpoint
        aggregates per user and\npaginates.\n\n* **Authorization — anyone who can
        open the wiki.** Note this is\n  deliberately *looser* than the sibling `GET
        /wikis/{id}/viewers`, which\n  is admin/creator-only: who **viewed** a page
        is private, but who\n  **reacted** is public on the web to every reader. The
        wiki is resolved\n  within the caller's visible scope, so an id they cannot
        see returns\n  `404` rather than revealing that it exists.\n* **Search** —
        `search` filters reactors by name, first name, last name or\n  email (case-insensitive
        substring). A `%`/`_` typed by the user matches\n  literally. Identical to
        the comment-level sibling: both go through the\n  same `Platform::Reactable#reactors_scope`,
        so the two endpoints cannot\n  differ on which columns match.\n* **`reaction_counts`
        and `total_reactions` describe the WHOLE wiki and\n  are NOT narrowed by `search`**
        — they are the reaction bar's summary,\n  so they must not change while the
        user types in the people filter.\n  Only `meta.total_count` and the `reactors`
        list honor `search`.\n\nWikis accept the company's reaction palette (its admin's
        choice from the one reaction catalog, the whole catalog unless narrowed).\n"
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Filter reactors by name / first name / last name / email (case-insensitive
          substring).
        schema:
          type: string
        example: Dana
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, maximum 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Reactors retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - reactors
                - reaction_counts
                - total_reactions
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for the whole wiki, e.g. `{\"\U0001F44D\":
                      4, \"❤️\": 2}`. NOT narrowed by `search`. Empty object when
                      nobody has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on the
                      wiki, not the number of people. NOT narrowed by `search`.
                    example: 6
                  reactors:
                    type: array
                    description: One row per USER, most-recently-reacted first. Honors
                      `search` and pagination.
                    items:
                      type: object
                      required:
                      - user_id
                      - name
                      - job_title
                      - image_url
                      - reactions
                      - reaction_count
                      - last_reacted_at
                      properties:
                        user_id:
                          type: integer
                          example: 1884
                        name:
                          type: string
                          description: Full name, falling back to the display name.
                          example: Dana Lee
                        job_title:
                          type: string
                          nullable: true
                          description: The reactor's job title in this business (null
                            if unset). Resolves both the normalized job title and
                            the legacy free-text value.
                          example: Store Manager
                        image_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (null if unavailable).
                          example: https://officechat.workforce.mangoapps.com/avatars/1884.png
                        reactions:
                          type: array
                          description: Every emoji this person left on the wiki, oldest
                            first. Always non-empty.
                          items:
                            type: string
                          example:
                          - "\U0001F44D"
                          - "❤️"
                        reaction_count:
                          type: integer
                          description: How many reactions this person left (>= 1).
                          example: 2
                        last_reacted_at:
                          type: string
                          format: date-time
                          description: When this user most recently reacted (ISO-8601).
                          example: '2026-07-30T05:29:11Z'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Distinct reactor count (honors search).
                        example: 5
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        description: 0 when there are no reactors.
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
            Unlike `/wikis/{id}/viewers`, being a non-admin is NOT a 403 here.
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/reactions":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Toggle the caller's emoji reaction on a wiki
      description: |
        Adds or removes the caller's emoji reaction on a wiki page — this is a
        **toggle** (the platform `Reactable#toggle_reaction` contract and the web
        reaction bar in `shared/_platform_reaction_bar`): sending an emoji the
        caller has **not** left ADDS it; sending one they **already** left REMOVES
        it. A user may hold several DIFFERENT emojis at once — each emoji is its
        own reaction toggled independently.

        The allowed emoji are the company's reaction palette (its admin's choice from the one reaction catalog, the whole catalog unless narrowed), plus one the
        caller already holds from outside it (so it can be removed); any other
        value returns `422 invalid_emoji` with the `allowed` list.

        * **Authorization — anyone who can open the wiki.** Same gate as
          `GET /wikis/{id}/reactors` (deliberately *looser* than
          `GET /wikis/{id}/viewers`): the reaction bar is public to every reader
          on the web. The wiki is resolved within the caller's visible scope, so
          an id they cannot see returns `404` rather than revealing that it
          exists. The token asks `write:wikis`, as every change here does.

        The response echoes the updated reaction-bar summary
        (`reaction_counts`, `total_reactions`, `my_reactions`) so the client can
        repaint the bar without a follow-up `GET`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: One emoji from the company's reaction palette.
                  example: "\U0001F44D"
      responses:
        '200':
          description: Reaction toggled. `reacted` is `true` when the caller's reaction
            is now present, `false` when it was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - emoji
                - reacted
                - reaction_counts
                - total_reactions
                - my_reactions
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  emoji:
                    type: string
                    description: The emoji that was toggled.
                    example: "\U0001F44D"
                  reacted:
                    type: boolean
                    description: True if the caller's reaction is now present; false
                      if it was removed.
                    example: true
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for the whole wiki after the toggle,
                      e.g. `{\"\U0001F44D\": 4, \"❤️\": 2}`. Empty object when nobody
                      has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on the
                      wiki (not the number of people).
                    example: 6
                  my_reactions:
                    type: array
                    description: The emojis the CALLER currently has on this wiki.
                    items:
                      type: string
                    example:
                    - "\U0001F44D"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '422':
          description: The `emoji` is missing (error code `emoji_required`) or outside
            the allowed set (error code `invalid_emoji`; the response `error.details.allowed`
            lists the accepted emoji).
  "/wikis/{id}/bookmark":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Bookmark a wiki for the current user
      description: |
        Bookmarks the wiki for the **calling user**, writing the platform-wide
        `Platform::Bookmark` shared with the `/bookmarks` page and the web
        bookmark button. **Idempotent** — bookmarking an already-bookmarked wiki
        is a no-op that still returns `bookmarked: true` and never creates a
        second row.

        **Supersedes `POST /wikis/{id}/pin`**, which is still served as a
        deprecated alias of this endpoint for native builds shipped before the
        rename (same rows, same contract). The Wikis app used to carry an
        app-local pin alongside Bookmarks — two ways to mark the same page — and
        the pin was removed. Clients calling the old path must move to this one.

        * **Authorization — anyone who can open the wiki.** The wiki is resolved
          within the caller's visible scope, so an id they cannot see returns
          `404` rather than revealing that it exists. Any caller with Wikis-app
          access may bookmark; the token asks `write:wikis`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki bookmarked (or already bookmarked).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - bookmarked
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  bookmarked:
                    type: boolean
                    description: The bookmark state for the caller AFTER this call
                      (always `true` here).
                    example: true
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
    delete:
      tags:
      - Wikis
      summary: Remove the current user's bookmark on a wiki
      description: |
        Removes the calling user's bookmark on the wiki. **Idempotent** —
        removing a bookmark that isn't there is a no-op that still returns
        `bookmarked: false`. Same authorization as the POST verb.

        A bookmark carrying a note is soft-deleted (recoverable from the
        platform Trash); a bare one is removed outright, matching the web
        toggle.

        **Supersedes `DELETE /wikis/{id}/pin`**, which is still served as a
        deprecated alias of this endpoint for native builds shipped before the
        rename (same rows, same contract).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Bookmark removed (or there was none).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - bookmarked
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  bookmarked:
                    type: boolean
                    description: The bookmark state for the caller AFTER this call
                      (always `false` here).
                    example: false
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
  "/wikis/{id}/archive":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Archive a wiki
      description: |
        Archives the wiki — sets its status to `archived`, mirroring the web
        "Archive" action (Apps::Wikis::PagesController#update with
        `wiki[status]=archived`). An archived wiki drops out of the default
        browse tree and can be unarchived. Archive is a status; deleting a wiki
        is DELETE /wikis/{id}, and POST /wikis/{id}/restore brings it back.
        **Idempotent** — archiving an already-archived wiki is a no-op that
        still returns status `archived`.

        * **Authorization — the web's status-change gate, faithfully** (its
          `can_manage_wiki?` AND `can_edit_wiki?`, the same pair the unarchive
          twin below documents): a business **admin (or above)** may always
          archive; the wiki's **creator** may archive **only while the wiki is
          not locked** (a locked wiki fails `can_edit_wiki?` for a non-admin).
          Any other caller who can see the wiki gets `403` (`forbidden`); a
          caller who cannot even see the wiki gets `404` (resolved out by the
          visible scope, never revealing it exists). This is a per-wiki check;
          the token asks `destructive:wikis` (below).
        * **A wiki someone else is actively editing answers `409`**, not `403` —
          the lock is transient, so retrying after it clears succeeds.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:wikis` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki archived (or already archived).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - status
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  status:
                    type: string
                    description: The wiki's status after the call.
                    enum:
                    - archived
                    example: archived
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller may not archive this wiki — they are neither a business
            admin nor its (unlocked-wiki) creator (error code `forbidden`), or the
            Wikis app is not accessible to them (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '409':
          description: Another user currently holds the edit lock on this wiki (error
            code `wiki_being_edited`); the message names the lock holder. Distinct
            from `403` because the condition is transient — retry once the lock clears.
    delete:
      tags:
      - Wikis
      summary: Unarchive a wiki
      description: |
        Unarchives the wiki — sets its status back to `draft`, mirroring the web
        "Restore to Draft" action (Apps::Wikis::PagesController#update with
        `wiki[status]=draft`). It restores to **draft** (NOT `published`), so the
        owner can review before re-publishing. **Idempotent** — calling it on a
        wiki that isn't archived is a no-op that returns the wiki's current
        status unchanged.

        * **Authorization — the web's status-change gate, faithfully** (its
          `can_manage_wiki?` AND `can_edit_wiki?`): a business **admin (or above)**
          may always unarchive; the wiki's **creator** may unarchive **only while
          the wiki is not locked** (a locked wiki fails `can_edit_wiki?` for a
          non-admin, so a locked archived page can be restored by an admin only).
          Any other caller who can see the wiki gets `403` (`forbidden`); a caller
          who cannot even see it gets `404`. Note that an archived wiki is visible
          only to admins and its creator, so a non-creator member typically gets
          `404` here rather than `403`. This is a per-wiki check; the token asks
          `write:wikis` — unarchiving restores, so it is not delete-level.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki unarchived (or already not archived).
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - status
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path id).
                    example: 42
                  status:
                    type: string
                    description: The wiki's status after the call — `draft` after
                      restoring an archived wiki, or its unchanged current status
                      on a no-op.
                    enum:
                    - draft
                    - published
                    example: draft
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller may not unarchive this wiki — they are neither a
            business admin nor its (unlocked-wiki) creator (error code `forbidden`),
            or the Wikis app is not accessible to them (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller (error code `not_found`).
        '409':
          description: Another user currently holds the edit lock on this wiki (error
            code `wiki_being_edited`); the message names the lock holder. Distinct
            from `403` because the condition is transient — retry once the lock clears.
  "/wikis/{wiki_id}/comments/{id}/reactions":
    parameters:
    - name: wiki_id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment id. A reply is the same comment model (threaded), so
        this handles comments AND replies.
      schema:
        type: integer
    post:
      tags:
      - Wikis
      summary: Toggle the caller's emoji reaction on a wiki comment or reply
      description: |
        Adds or removes the caller's emoji reaction on a wiki **comment** — or a
        **reply**, which is the same threaded comment model, so this one endpoint
        handles both. This is a **toggle** (the platform
        `Reactable#toggle_reaction` contract and the web comment reaction bar in
        `Apps::Wikis::CommentsController#react`): sending an emoji the caller has
        **not** left ADDS it; sending one they **already** left REMOVES it. A user
        may hold several DIFFERENT emojis at once — each is toggled independently.

        The allowed emoji are the company's reaction palette (its admin's choice from the one reaction catalog, the whole catalog unless narrowed), plus one the
        caller already holds from outside it (so it can be removed); any other
        value returns `422 invalid_emoji` with the `allowed` list.

        * **Authorization — anyone who can open the wiki** (same as the page
          reaction bar). The wiki is resolved within the caller's visible scope,
          and the comment is resolved THROUGH that wiki's own thread, so a
          restricted / cross-tenant / foreign / missing id all return `404`
          rather than revealing that it exists. The token asks `write:wikis`.

        The response echoes the updated reaction-bar summary for the comment
        (`reaction_counts`, `total_reactions`, `my_reactions`) so the client can
        repaint the bar without a follow-up `GET`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: One emoji from the company's reaction palette.
                  example: "\U0001F44D"
      responses:
        '200':
          description: Reaction toggled. `reacted` is `true` when the caller's reaction
            is now present, `false` when it was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - comment_id
                - emoji
                - reacted
                - reaction_counts
                - total_reactions
                - my_reactions
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path).
                    example: 42
                  comment_id:
                    type: integer
                    description: The comment (or reply) id (echoes the path).
                    example: 57
                  emoji:
                    type: string
                    description: The emoji that was toggled.
                    example: "\U0001F44D"
                  reacted:
                    type: boolean
                    description: True if the caller's reaction is now present; false
                      if it was removed.
                    example: true
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for the comment after the toggle,
                      e.g. `{\"\U0001F44D\": 4, \"❤️\": 2}`. Empty object when nobody
                      has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on the
                      comment.
                    example: 6
                  my_reactions:
                    type: array
                    description: The emojis the CALLER currently has on this comment.
                    items:
                      type: string
                    example:
                    - "\U0001F44D"
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller, or no such comment
            on that wiki (error code `not_found`).
        '422':
          description: The `emoji` is missing (error code `emoji_required`) or outside
            the allowed set (error code `invalid_emoji`; the response `error.details.allowed`
            lists the accepted emoji).
  "/wikis/{wiki_id}/comments/{id}/reactors":
    parameters:
    - name: wiki_id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment id. A reply is the same comment model (threaded), so
        this handles comments AND replies.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: People who reacted to a wiki comment (+ per-emoji counts)
      description: |
        Returns the users who reacted to one wiki **comment or reply**,
        **most-recently-reacted first**, aggregated **per user** — one row per
        person carrying every emoji they left — plus that comment's per-emoji
        `reaction_counts`.

        This is the read side of `POST /wikis/{wiki_id}/comments/{id}/reactions`,
        which reports only the caller's own state and the totals. It returns the
        **same row and `meta` shape** as the page-level
        `GET /wikis/{id}/reactors`, so one client parser handles both.

        * **Replies are the same endpoint.** A reply is the same comment model
          (threaded via `parent_comment_id`, still attached to the wiki), so pass
          either a top-level comment id or a reply id. A reply's reactors are
          independent of its parent's.
        * **Authorization — anyone who can open the wiki.** Matching both the web
          comment reaction bar and the page-level reactors endpoint, and
          deliberately *looser* than `GET /wikis/{id}/viewers`, which is
          admin/creator-only: who **viewed** a page is private, who **reacted** is
          not. The wiki is resolved within the caller's visible scope and the
          comment through that wiki's own thread, so a restricted, cross-tenant,
          cross-wiki or missing id all return `404` alike — never revealing that
          something the caller cannot see exists.
        * **Search** — `search` filters reactors by name, first name, last name or
          email (case-insensitive substring). A `%`/`_` typed by the user matches
          literally.
        * **`reaction_counts` and `total_reactions` describe the WHOLE comment and
          are NOT narrowed by `search`** — they are the comment's reaction-bar
          summary, so they must not change while the user types in the people
          filter. Only `meta.total_count` and the `reactors` list honor `search`.
        * **Counts are scoped to this comment** — a reaction on the wiki *page*, or
          on a sibling comment or reply, never appears here.

        Comments accept the company's reaction palette (its admin's choice from the one reaction catalog, the whole catalog unless narrowed).

        Query cost is flat at 5 queries regardless of page size — every field a row
        carries is resolved from a preloaded batch.
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        required: false
        description: Filter reactors by name / first name / last name / email (case-insensitive
          substring).
        schema:
          type: string
        example: Dana
      - name: page
        in: query
        required: false
        description: 1-based page number (default 1).
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page (default 20, maximum 50).
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 20
      responses:
        '200':
          description: Comment reactors retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - comment_id
                - reactors
                - reaction_counts
                - total_reactions
                - meta
                properties:
                  wiki_id:
                    type: integer
                    description: The wiki id (echoes the path `wiki_id`).
                    example: 42
                  comment_id:
                    type: integer
                    description: The comment (or reply) id (echoes the path `id`).
                    example: 907
                  reaction_counts:
                    type: object
                    additionalProperties:
                      type: integer
                    description: "Count per emoji for this COMMENT, e.g. `{\"\U0001F44D\":
                      4, \"❤️\": 2}`. NOT narrowed by `search`, and never includes
                      reactions left on the wiki page or on a sibling comment/reply.
                      Empty object when nobody has reacted."
                    example:
                      "\U0001F44D": 4
                      "❤️": 2
                  total_reactions:
                    type: integer
                    description: Sum of `reaction_counts` — total reactions on this
                      comment, not the number of people. NOT narrowed by `search`.
                    example: 6
                  reactors:
                    type: array
                    description: One row per USER, most-recently-reacted first. Honors
                      `search` and pagination.
                    items:
                      type: object
                      required:
                      - user_id
                      - name
                      - job_title
                      - image_url
                      - reactions
                      - reaction_count
                      - last_reacted_at
                      properties:
                        user_id:
                          type: integer
                          example: 1884
                        name:
                          type: string
                          description: Full name, falling back to the display name.
                            `"Unknown"` for a reaction left by someone no longer an
                            active member of this business.
                          example: Dana Lee
                        job_title:
                          type: string
                          nullable: true
                          description: The reactor's job title in this business (null
                            if unset). Resolves both the normalized job title and
                            the legacy free-text value.
                          example: Store Manager
                        image_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (null if unavailable).
                          example: https://officechat.workforce.mangoapps.com/avatars/1884.png
                        reactions:
                          type: array
                          description: Every emoji this person left on THIS comment,
                            oldest first. Always non-empty.
                          items:
                            type: string
                          example:
                          - "\U0001F44D"
                          - "❤️"
                        reaction_count:
                          type: integer
                          description: How many reactions this person left (>= 1).
                          example: 2
                        last_reacted_at:
                          type: string
                          format: date-time
                          description: When this user most recently reacted to this
                            comment (ISO-8601).
                          example: '2026-07-30T05:29:11Z'
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                        description: Distinct reactor count (honors search).
                        example: 5
                      current_page:
                        type: integer
                        example: 1
                      total_pages:
                        type: integer
                        description: 0 when there are no reactors.
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
            Unlike `/wikis/{id}/viewers`, being a non-admin is NOT a 403 here.
        '404':
          description: No wiki with that id is visible to the caller, or no such comment
            on that wiki (error code `not_found`).
  "/wikis/{wiki_id}/comments/{id}":
    parameters:
    - name: wiki_id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: The comment id. A reply is the same threaded comment model, so
        this handles comments AND replies.
      schema:
        type: integer
    patch:
      tags:
      - Wikis
      summary: Edit a wiki comment or reply
      description: |
        Edits the body of a comment (or reply — the same threaded model). Allowed
        for the **author within 15 minutes** of posting, or an **admin** of the
        wiki's business at any time; otherwise 403 `cannot_edit`.

        **Mentions** are re-parsed from the new `body` — the same
        `@[Name](mention:id)` tokens as create — so `mentioned_user_ids` reflects
        the edited text. **Attachments** sent as multipart `attachments[]` are
        APPENDED to the comment (existing attachments are kept). The edit stamps
        `edited_at`, which the read API exposes so clients can show an "edited"
        marker. No notifications are fired on edit.

        On success returns the updated comment in the same shape as the thread
        list rows (`{ comment: WikiComment }`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: New comment text; may contain `@[Name](mention:id)`
                    mention tokens. Max 2000 chars.
                  example: Updated — thanks @[Casey Poster](mention:49290)
                attachments:
                  type: array
                  description: Additional files to append (up to 5, 10 MB each).
                  items:
                    type: string
                    format: binary
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  example: Updated — thanks @[Casey Poster](mention:49290)
      responses:
        '200':
          description: Comment updated
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/WikiComment"
                  mentions_not_notified:
                    type: array
                    description: People this edit newly mentioned who were not notified
                      because they cannot read the wiki (empty when everyone mentioned
                      can read it, while the tenant has mention notifications switched
                      off, or if sending them failed).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  mention_notices_off:
                    type: boolean
                    description: True when this edit added an @mention of someone
                      besides its author while the tenant has "Notify Users on @Mention"
                      switched off in Wikis Settings — nobody was told they were mentioned
                      (`mentions_not_notified` is then empty). The web and phone readers
                      show the author a warning.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible, or the caller may no longer
            edit this comment — not the author, or past the 15-minute edit window
            (error code `cannot_edit`).
        '404':
          description: No wiki with that id is visible to the caller, or no comment
            with that id exists on this wiki (error code `not_found`).
        '422':
          description: Validation failed (e.g. blank body).
    delete:
      tags:
      - Wikis
      summary: Delete a wiki comment or reply
      description: |
        Soft-deletes a comment (or reply — the same threaded model). Allowed for
        the comment's **author within 5 minutes** of posting; the **page's
        manager**, a **business admin** or a **Wikis app admin** at any time;
        otherwise 403 `cannot_delete`.

        The delete is soft: the row leaves the thread immediately (subsequent GETs
        omit it and it stops counting toward `meta.total_count`) but is retained
        for the audit window. Deleting a top-level comment removes its replies from
        the thread as well.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:wikis` scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                required:
                - id
                - deleted
                properties:
                  id:
                    type: integer
                    example: 8842
                  deleted:
                    type: boolean
                    example: true
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible, or the caller may no longer
            delete this comment — not its author (nor the page's manager or an admin),
            or past the author's 5-minute delete window (error code `cannot_delete`).
        '404':
          description: No wiki with that id is visible to the caller, or no comment
            with that id exists on this wiki (error code `not_found`).
  "/wikis/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: The wiki id.
      schema:
        type: integer
    get:
      tags:
      - Wikis
      summary: Full detail for one wiki (records a view)
      description: |
        Full detail for a single wiki — the native mirror of the web reader pane
        (`apps/wikis/shared/_wiki_viewer`).

        * **Authorization — only users who may VIEW the wiki.** An admin sees any
          status; a member sees a published wiki visible to them (everyone, a
          group they belong to, or their own page of any status). Any wiki they
          cannot view — restricted, another user's draft, another tenant's, or a
          missing id — returns `404` identically, so the endpoint never reveals
          that a wiki exists.
        * **View count.** Like the web reader, a successful read records a
          `WikiViewEvent` (deduplicated to one per user per 30 minutes) and
          increments `views_count`. The `views_count` in THIS response is the
          pre-increment figure (the view is recorded after the payload is built).
        * `can_archive` / `can_delete` reflect the web `can_manage_wiki?` gate:
          `true` for a business admin-or-above OR the wiki's creator.
          `can_archive` is additionally `false` when the wiki is already archived.
          Both are also `false` while another user is editing the page; `can_delete`
          too while someone is editing one of its sub-wikis (DELETE answers `409`
          `wiki_being_edited`).
        * `comments_enabled` is whether the page is open to comments at all,
          independent of the caller — `false` only when **"Who can comment?"** is
          `nobody`. `can_comment` resolves that same setting for THIS caller, folding
          in publish state; it is the same predicate the write path enforces, so a
          comment composer rendered from it can never 403 on submit. The two together
          tell "comments are off for this page" apart from "you specifically may not
          comment". (The stored **"Enable comments"** master toggle that
          `comments_enabled` used to report was removed as a duplicate of
          **"Who can comment?"**; the field is unchanged in name, type and meaning
          and is now derived from that setting.)
        * `author` is the byline, honouring the page's **"Show Author"** setting
          (null when off); `creator` is the person who manages the page and is always present.
        * `show_toc` is the page's stored **"Show Table of Contents"** setting; the
          ToC entries themselves are not part of this payload.
        * `reaction_counts` is the per-emoji breakdown; `total_reactions` its
          sum; `distinct_reactions` the number of distinct emoji;
          `current_user_reactions` the emoji the CALLER left.
        * `bookmarked` is whether the **CALLER** has bookmarked this wiki (a
          bookmark is per-user, so it is never "somebody bookmarked it"). It is
          the same key `POST` / `DELETE /wikis/{id}/bookmark` return, so the
          toggle response can be
          written straight back onto a detail you are holding.
        * `parents` is the wiki's ancestor chain for a breadcrumb — **root first**,
          excluding the wiki itself, `[]` for a top-level wiki. Ancestors the
          caller cannot view are **omitted**, so the chain can have gaps: a
          published page may hang under another user's draft, and crumbing it
          would hand the caller the title of a page this endpoint would itself
          `404`. Combine with `sub_wikis` to place the wiki in the tree —
          `parents` looks up, `sub_wikis` looks down.
        * `sub_wikis` is **capped at 200 nodes**, cut breadth-first so the tree you
          get is always connected (a node appears only if its parent does).
          `sub_wikis_total` is how many nodes the subtree really holds and
          `sub_wikis_truncated` says whether the cap bit; both are additive, so a
          client that ignores them is unaffected. Nothing shipping today reaches
          the cap — the largest subtree measured across the fleet is well under it
          — it bounds a response that otherwise grows with nothing but how many
          sub-pages someone filed under one wiki.
        * **Deleting is reversible and the API owns both halves:**
          `DELETE /wikis/{id}` moves the page and its sub-tree to Trash,
          `GET /wikis/trash` lists what is in there and
          `POST /wikis/{id}/restore` brings a page (and its cascade) back.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki detail retrieved successfully
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki
                properties:
                  wiki:
                    type: object
                    required:
                    - id
                    - title
                    - description
                    - image_url
                    - web_view_url
                    - copy_link_url
                    - status
                    - is_archived
                    - last_updated_at
                    - current_version
                    - comments_count
                    - views_count
                    - creator
                    - show_author
                    - author
                    - groups
                    - tags
                    - show_toc
                    - can_archive
                    - can_delete
                    - comments_enabled
                    - can_comment
                    - total_reactions
                    - distinct_reactions
                    - reaction_counts
                    - current_user_reactions
                    - bookmarked
                    - attachments
                    - parents
                    - sub_wikis
                    - sub_wikis_total
                    - sub_wikis_truncated
                    properties:
                      id:
                        type: integer
                        example: 42
                      title:
                        type: string
                        example: Apache Zookeeper
                      description:
                        type: string
                        nullable: true
                        description: The page's lead (Properties → Description, up
                          to 500 characters), which the web reader prints under the
                          title. Plain text; null when the page has none. Not part
                          of the `web_view_url` body — draw it in the native screen.
                        example: How we run and monitor the coordination cluster.
                      image_url:
                        type: string
                        nullable: true
                        description: Absolute cover-image URL (null when none is attached).
                        example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/x/cover.png
                      web_view_url:
                        type: string
                        nullable: true
                        description: Absolute URL of the MOBILE web view (`/m/apps/wikis/:id`)
                          — the bare, description-only reader meant to be embedded
                          in a native WebView. Always carries `?embed=1`, which is
                          what strips the web chrome; load this url VERBATIM. Dropping
                          the param falls back to User-Agent detection, and a WebView
                          whose UA carries no MangoApps token then renders the full
                          mobile chrome (a second top bar plus the bottom tab bar)
                          inside the native screen.
                        example: https://officechat.workforce.mangoapps.com/m/apps/wikis/42?embed=1
                      copy_link_url:
                        type: string
                        nullable: true
                        description: Absolute shareable permalink to this page — the
                          same link the web reader's "Copy link" button copies (`/apps/wikis/pages/:id`).
                        example: https://officechat.workforce.mangoapps.com/apps/wikis/pages/42
                      status:
                        type: string
                        enum:
                        - draft
                        - published
                        - archived
                        example: published
                      is_archived:
                        type: boolean
                        description: 'Whether the wiki is archived — `status == "archived"`
                          as a boolean, so a client can branch without string-matching.
                          Pair it with `can_archive`, which is false both for an already-archived
                          page and for a caller who may not archive: `is_archived`
                          is what tells you whether to offer **Archive** or **Unarchive**
                          (`POST` / `DELETE /wikis/{id}/archive`).'
                        example: false
                      last_updated_at:
                        type: string
                        format: date-time
                        description: When the wiki was last updated (ISO-8601).
                        example: '2026-07-30T05:29:11Z'
                      current_version:
                        type: integer
                        description: Highest version number recorded (0 when the wiki
                          has no versions).
                        example: 3
                      comments_count:
                        type: integer
                        description: |-
                          Number of (non-deleted) TOP-LEVEL comments on the wiki. Replies are NOT counted — every implementation (details#show and all three web call sites) computes `wiki_comments.top_level.count`, and this line said "replies included" until 2026-09-05. The thread endpoint's own `meta.total_count` uses the same rule, so the two agree.

                          **`0` whenever `comments_enabled` is false.** "Who can comment? → Nobody" retracts the thread everywhere — the web reader replaces it with "Comments are disabled on this wiki." and `GET /wikis/{id}/comments` returns an empty list — so this counts what the app will actually show, never a retracted thread. The two fields can no longer contradict each other in one response.
                        example: 5
                      views_count:
                        type: integer
                        description: |
                          Total views (pre-increment for this read). While a works
                          council hides fewer of the page's readers than the company's
                          smallest reportable group, the views of the named viewers
                          only — the count every door shows (Knowledge Layer X3).
                        example: 128
                      creator:
                        type: object
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 1884
                          name:
                            type: string
                            example: Dana Lee
                      show_author:
                        type: boolean
                        description: The page's own "Show Author" setting (Properties).
                          When false the web reader prints no byline — and `author`
                          below is null.
                        example: true
                      author:
                        type: object
                        nullable: true
                        description: 'The byline to render: the same person as `creator`,
                          but **null when `show_author` is false**. Use this for the
                          byline and `creator` for ownership — the author can hide
                          the byline without the page becoming ownerless, so `creator`
                          is always reported.'
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            nullable: true
                            example: 1884
                          name:
                            type: string
                            example: Dana Lee
                      owner:
                        type: object
                        nullable: true
                        description: The page's named owner (Properties › Owner) —
                          who answers for keeping it accurate, not who manages it
                          (`creator`). Null when none is named or that person is no
                          longer an active member.
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            example: 455
                          name:
                            type: string
                            example: Priya Shah
                      owner_team:
                        type: object
                        nullable: true
                        description: The owning team (Properties › Owner), null when
                          none or no longer active.
                        required:
                        - id
                        - name
                        properties:
                          id:
                            type: integer
                            example: 31
                          name:
                            type: string
                            example: People Ops
                      owned_by:
                        type: string
                        nullable: true
                        description: 'The owner line readers see: the owner and/or
                          team, else the person who manages the page standing in,
                          written "Name (manages it)". An ownership fact, not the
                          byline, so it is reported whatever `show_author` says.'
                        example: Priya Shah
                      groups:
                        type: array
                        description: Visibility groups gating the wiki (empty for
                          everyone / just_me visibility).
                        items:
                          type: object
                          required:
                          - id
                          - name
                          properties:
                            id:
                              type: integer
                              example: 7
                            name:
                              type: string
                              example: Engineering
                      tags:
                        type: array
                        description: The wiki's tags, sorted by name.
                        items:
                          type: object
                          required:
                          - id
                          - name
                          properties:
                            id:
                              type: integer
                              example: 12
                            name:
                              type: string
                              example: devops
                      can_archive:
                        type: boolean
                        description: May the caller archive this wiki (admin or creator,
                          and not already archived).
                        example: true
                      can_delete:
                        type: boolean
                        description: May the caller delete this wiki — admin or creator,
                          and nobody else is editing it or one of the sub-wikis it
                          would take to Trash.
                        example: true
                      can_share:
                        type: boolean
                        description: 'May the caller share this wiki (POST /shares):
                          anyone who can open it, with a `write:shares` token.'
                        example: true
                      comments_enabled:
                        type: boolean
                        description: 'Whether the page is open to comments at all,
                          independent of the caller — `false` only when **"Who can
                          comment?"** is `nobody`, which is how a page closes comments.
                          Use `can_comment` for whether THIS caller may actually post;
                          the two differ on a page that is open but restricted (`admin_and_creator`).
                          On a `nobody` page they agree: `comments_enabled: false`
                          forces `can_comment: false` for **every** caller, admins
                          included — the closed-thread check runs BEFORE the admin
                          override, matching the web reader, which replaces the whole
                          thread with "Comments are disabled on this wiki." for every
                          persona. Formerly the stored **"Enable comments"** master
                          toggle, removed as a duplicate of **"Who can comment?"**
                          and now derived from it — same name, type and meaning.'
                        example: true
                      can_comment:
                        type: boolean
                        description: May the CALLER post a comment. `true` only when
                          the wiki is **published** and the page's **"Who can comment?"**
                          setting admits them (`any_user` → anyone, `admin_and_creator`
                          → the creator, `nobody` → **no one, including admins**).
                          The `nobody` check runs BEFORE the admin override, so an
                          admin does NOT keep a composer on a page closed to comments;
                          on every other setting an admin bypasses the restriction.
                          This is the exact predicate `POST /wikis/{id}/comments`
                          enforces, so a composer rendered on this flag can never
                          be refused on submit.
                        example: true
                      show_toc:
                        type: boolean
                        description: The page's stored **"Show Table of Contents"**
                          setting. The ToC entries themselves are not part of this
                          payload — this flag only mirrors the author's choice.
                        example: true
                      total_reactions:
                        type: integer
                        description: Total reactions on the wiki (sum of reaction_counts).
                        example: 6
                      distinct_reactions:
                        type: integer
                        description: Number of distinct emoji used (keys of reaction_counts).
                        example: 3
                      reaction_counts:
                        type: object
                        additionalProperties:
                          type: integer
                        description: "Count per emoji (e.g. four \U0001F44D and two
                          ❤️). Empty when nobody has reacted."
                        example:
                          "\U0001F44D": 4
                          "❤️": 2
                      current_user_reactions:
                        type: array
                        description: The emoji the CALLER left on this wiki (empty
                          if none).
                        items:
                          type: string
                        example:
                        - "\U0001F44D"
                      bookmarked:
                        type: boolean
                        description: Whether the CALLER has bookmarked this wiki.
                          A bookmark is per-user, so this is the caller's own state
                          and never "somebody bookmarked it" — render the reader's
                          bookmark toggle from it on first paint. Same key POST/DELETE
                          /wikis/{id}/bookmark return. Supersedes the former `pinned`
                          key, which is still mirrored below for pre-rename builds.
                        example: true
                      pinned:
                        type: boolean
                        deprecated: true
                        description: DEPRECATED mirror of `bookmarked`, kept for native
                          builds shipped before the pin→bookmark rename. Computed
                          from the same predicate, so the two can never disagree.
                          New integrations read `bookmarked`.
                        example: true
                      attachments:
                        type: array
                        description: The wiki's page-level attachments (max 5). Each
                          carries an absolute `url` and `download_url`; `is_image`
                          is true for images the client can render inline and false
                          for download chips.
                        items:
                          type: object
                          required:
                          - id
                          - filename
                          - content_type
                          - byte_size
                          - is_image
                          - url
                          - download_url
                          properties:
                            id:
                              type: integer
                              example: 2633
                            filename:
                              type: string
                              example: runbook.pdf
                            content_type:
                              type: string
                              nullable: true
                              example: application/pdf
                            byte_size:
                              type: integer
                              example: 30553
                            is_image:
                              type: boolean
                              example: false
                            url:
                              type: string
                              nullable: true
                              description: 'Absolute URL for opening the attachment
                                (null if the blob is unreadable). An IMAGE is served
                                `Content-Disposition: inline` so it can be previewed
                                in place; every other file — a PDF above all — is
                                served `Content-Disposition: attachment`, because
                                a client with no built-in PDF renderer can neither
                                display an inline PDF nor hand it to a download, so
                                the tap does nothing at all.'
                              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/x/runbook.pdf?disposition=attachment
                            download_url:
                              type: string
                              nullable: true
                              description: 'Absolute URL that ALWAYS responds `Content-Disposition:
                                attachment` — the target for an explicit Download
                                action, images included. Identical to `url` for every
                                non-image attachment.'
                              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/x/runbook.pdf?disposition=attachment
                      parents:
                        type: array
                        description: The wiki's ancestor chain, ROOT FIRST and excluding
                          the wiki itself — render it as breadcrumbs above the title.
                          Empty for a top-level wiki. Ancestors the caller cannot
                          view are DROPPED (so the chain may skip a level) rather
                          than leaking the title of a page this endpoint would 404;
                          a trashed ancestor truncates the chain there.
                        items:
                          type: object
                          required:
                          - id
                          - title
                          properties:
                            id:
                              type: integer
                              example: 4188
                            title:
                              type: string
                              example: Employee Handbook
                        example:
                        - id: 4188
                          title: Employee Handbook
                        - id: 4192
                          title: Benefits
                        - id: 4201
                          title: Dental
                      sub_wikis:
                        type: array
                        description: |-
                          The nested tree of this wiki's sub-wikis — every published, caller-visible descendant, recursively. Each node is a card that itself carries a `sub_wikis` array (empty at the leaves). A descendant reachable only through a hidden (draft/restricted) parent does not appear. Same visibility as GET /wikis/{id}/sub_wikis, but that endpoint returns ONE level while this is the whole subtree.

                          **Capped at 200 nodes**, cut BREADTH-FIRST so what you receive is the top of the outline — a coherent partial hierarchy, never a dangling branch: a node is present only if its parent is, so `sub_wikis` can always be rendered as a tree as-is. Check `sub_wikis_truncated` before treating it as complete, and fetch the missing levels one at a time with GET /wikis/{id}/sub_wikis.
                        items:
                          "$ref": "#/components/schemas/WikiSubTreeNode"
                      sub_wikis_total:
                        type: integer
                        description: How many nodes the whole subtree holds — every
                          published, caller-visible descendant, counted before the
                          200-node cap. Equal to the number of nodes in `sub_wikis`
                          when `sub_wikis_truncated` is false.
                        example: 6
                      sub_wikis_truncated:
                        type: boolean
                        description: True when the cap cut the tree, i.e. `sub_wikis`
                          holds fewer than `sub_wikis_total` nodes. **Additive** —
                          a client that never reads it behaves exactly as before.
                        example: false
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The Wikis app is not accessible to the caller (error code `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller — missing, restricted,
            another user's draft, or another tenant's (error code `not_found`).
    delete:
      tags:
      - Wikis
      summary: Delete a wiki (soft-delete → Trash)
      description: |
        Soft-deletes a wiki — the native mirror of the web reader's delete action
        (`Apps::Wikis::PagesController#destroy` → `Wiki#discard!`). The page AND
        its entire sub-tree are moved to Trash in one transaction: they leave every
        list/read immediately but stay recoverable from web Trash. This is NOT a
        permanent delete.

        * **Authorization — only users who may MANAGE the wiki** (the web
          `can_manage_wiki?` gate): a business admin-or-above OR the wiki's
          creator. A member who can merely VIEW the page gets `403`; a page the
          caller cannot see at all returns `404`, never revealing it exists.
        * `sub_wikis_deleted` reports how many descendant pages were trashed with
          it (the cascade).
        * Deleting an already-trashed page returns `404` (it has left the default
          scope), so the call is safe to retry.

        Delete-level (owner decision 2026-10-04, #6659): requires the `destructive:wikis`
        scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Wiki moved to trash
          content:
            application/json:
              schema:
                type: object
                required:
                - wiki_id
                - deleted
                - sub_wikis_deleted
                - message
                properties:
                  wiki_id:
                    type: integer
                    description: The id of the deleted wiki.
                    example: 2633
                  deleted:
                    type: boolean
                    description: Always true on a 200 (the wiki was moved to Trash).
                    example: true
                  sub_wikis_deleted:
                    type: integer
                    description: How many descendant sub-pages were trashed with it
                      (cascade).
                    example: 2
                  message:
                    type: string
                    example: Wiki moved to trash.
                  _meta:
                    "$ref": "#/components/schemas/ResponseMeta"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The caller may view the wiki but is neither an admin-or-above
            nor its creator, OR the Wikis app is not accessible to them (error code
            `access_denied`).
        '404':
          description: No wiki with that id is visible to the caller — missing, restricted,
            another user's draft, another tenant's, or already trashed (error code
            `not_found`).
        '409':
          description: Another user is editing this wiki, or one of the sub-wikis
            the delete would move to Trash with it (error code `wiki_being_edited`);
            the message names the page and its editor when the caller can open that
            page. Distinct from `403` because the condition is transient — retry once
            they are done. `can_delete` on the detail payload is false while this
            applies.
  "/alerts":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: List the caller's received alerts (+ pending approvals)
      description: |
        Mirrors the broadcasts list API. Returns the caller's own alert inbox —
        alerts that were SENT to them (they have an `AlertDelivery` row), in
        sent/completed status — plus a `pending_approvals` array of alerts
        awaiting the caller's approval.

        Ordering mirrors the web Dashboard "Emergency Alerts" tab — most recent first (created_at DESC). Supports the subfilters via
        `filter`.

        `meta.segment_counts` is NOT the size of each tab's list. It counts
        what still NEEDS THE CALLER'S RESPONSE — the red-badge semantics — and
        is independent of the active `filter`. Two of the four therefore differ
        from their tab by design: `acknowledge` counts unacknowledged rows
        while its tab lists every ack-required alert (deliberately decoupled,
        ISS-20260713-662), and `all` counts alerts still owing a response while
        its list is the whole inbox, which additionally includes alerts the
        caller AUTHORED. `safety_check_in` and `draft` do match their tabs.
        Measured on a live tenant: all 24 vs 16, acknowledge 11 vs 10,
        safety_check_in 7 vs 7, draft 0 vs 0. Render tab badges from these
        counts only if you want "needs my response", not "how many rows".
      parameters:
      - name: filter
        in: query
        description: |
          Subfilter. Default `all`.
            * `all`             — every received alert (most recent first)
            * `acknowledge`     — `ack_required = true`
            * `safety_check_in` — `safety_check_in_required = true`
                                  (also accepts `safety` / `safety check in`)
            * `draft`           — the CALLER's OWN unsent alerts (authored +
                                  status 'draft'): plain drafts, ones awaiting
                                  approval, approved-pending-send, and rejected.
                                  These `alerts` items additionally carry
                                  `approval_state` + `approval_id` (and, when
                                  approved/rejected, `approval_decided_by`,
                                  `approval_decided_at`, `approval_notes`).
          (No `urgent` filter — the accountability invariant makes every alert
          urgent, so it would be identical to `all`.)
        required: false
        schema:
          type: string
          enum:
          - all
          - acknowledge
          - safety_check_in
          - draft
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Alerts listed (received inbox, most recent first)
          content:
            application/json:
              schema:
                type: object
                required:
                - alerts
                - pending_approvals
                - can_manage
                - meta
                properties:
                  alerts:
                    type: array
                    description: |
                      The filtered alert list. For `?filter=draft` these are the
                      caller's OWN unsent alerts and each item additionally carries
                      `approval_state` (draft | pending_approval | approved | rejected)
                      + `approval_id` (the latest approval request id, null for a
                      plain draft). Approved/rejected drafts also carry
                      `approval_decided_by` (reviewer name), `approval_decided_at`
                      (ISO-8601), and `approval_notes`. Other filters omit these fields.
                    items:
                      "$ref": "#/components/schemas/AlertSummary"
                  pending_approvals:
                    type: array
                    description: |
                      Alerts awaiting the CALLER's approval (pending Comms Hub
                      approval requests whose current step targets the caller's
                      role; admins see all). Same item shape as `alerts`.
                      Independent of pagination/?filter=.
                    items:
                      "$ref": "#/components/schemas/AlertSummary"
                  can_manage:
                    type: boolean
                    description: |
                      Root-level capability flag (not per item): whether the caller
                      can compose and stage emergency alerts — manager+, mirroring
                      the web authorize_alert_access gate — with a credential holding
                      write:broadcasts in full (an own-only or read-only token gets
                      false). Cancel is `can_cancel`, Send is `can_dispatch`, and the
                      Tracking rosters are each alert's `can_view_tracking`.
                  can_cancel:
                    type: boolean
                    description: Whether the caller may stand an alert down — cancel
                      it, discard a draft or withdraw its approval request. `can_manage`
                      AND a credential holding destructive:safety_hub, which those
                      doors ask (a signed-in session always does).
                  can_dispatch:
                    type: boolean
                    description: 'Whether the caller may DISPATCH an alert: `can_cancel`
                      AND the tenant''s per-tenant dispatch policy (who_can_dispatch_alerts).'
                  meta:
                    "$ref": "#/components/schemas/AlertListMeta"
        '401':
          description: Authentication required
        '403':
          description: The Broadcasts & Alerts app is not enabled for this business
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Create an alert — save as draft or publish (manager+)
      description: |
        Create an emergency alert. The client chooses the disposition via
        **`alert.status`**:
        - `"publish"` — the alert is **dispatched immediately** (status
          `sending`, the channel fan-out is enqueued), mirroring the web
          composer's **Send Now** (see **Approval** below).
        - **anything else** — `"draft"`, an **absent** status, or any
          unrecognized value — the alert is **saved as a draft only**: NOT routed
          through approval and NOT dispatched to recipients. Send it later via
          `POST /alerts/{id}/send_now`, or discard it via `DELETE /alerts/{id}`.
          The response carries `status: "draft"`.

        **Fail-safe default:** an alert is only ever sent when `alert.status` is
        explicitly `"publish"`; every other case is a draft.

        Requires **manager or above** (the web `authorize_alert_access` gate for
        emergency alerts).

        **Approval:** an emergency alert is never held for approval. When an
        ENFORCED approval workflow covers emergency alerts and the caller is not an
        admin, a *publish* still dispatches (`status: "sending"`); the override
        is recorded on the alert's approval request (status `overridden`) and the
        workflow's reviewers are told afterwards. Same as the web Send-Now. To ask
        for a review BEFORE sending, save a draft and call
        `POST /alerts/{id}/submit_for_approval`.

        **Targeting** is optional: supply any of `audience_id`,
        `notification_recipient_group_ids`, `alert.extra_user_ids`, or
        `alert.audience_criteria`. When none is supplied the alert targets
        EVERYONE in the business (the model's fallback), matching the web composer.

        Publishing (`alert[status]` = `publish`) sends the alert, which is
        delete-level (owner decision 2026-10-04): that request also requires
        `destructive:safety_hub`. A draft needs only `write:broadcasts`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - alert
              properties:
                alert:
                  type: object
                  required:
                  - title
                  - body
                  properties:
                    title:
                      type: string
                    body:
                      type: string
                    status:
                      type: string
                      enum:
                      - draft
                      - publish
                      description: |
                        Disposition of the new alert. `"publish"` runs the send
                        flow (immediate dispatch — an approval workflow never
                        holds an alert). Every other value — `"draft"`, an
                        ABSENT status, or anything unrecognized — saves it as a
                        draft only (NOT routed through approval, NOT dispatched;
                        send later via send_now, or discard via DELETE). Fail-safe:
                        only an explicit `"publish"` ever sends.
                    sms_body:
                      type: string
                    action_url:
                      type: string
                    urgent:
                      type: boolean
                    ack_required:
                      type: boolean
                    safety_check_in_required:
                      type: boolean
                    audience_id:
                      type: integer
                      nullable: true
                      description: A saved CommsHub audience to send to.
                    channels:
                      type: array
                      items:
                        type: string
                    extra_user_ids:
                      type: array
                      description: Specific user ids to send to (stored as the alert's
                        manual recipient list).
                      items:
                        type: integer
                    audience_criteria:
                      type: array
                      description: |
                        Attribute-based audience filters, each a typed hash — e.g.
                        `{ "type": "role", "roles": ["member"] }`,
                        `{ "type": "job_title", "titles": ["Area Manager"] }`,
                        `{ "type": "department", "ids": [1,2] }`,
                        `{ "type": "location", "ids": [3] }`. Validated + business-
                        scoped server-side.
                      items:
                        type: object
                        additionalProperties: true
                    media_signed_ids:
                      type: array
                      description: |
                        Attachments by reference: ActiveStorage blob
                        `signed_id`s the client already holds. Prefer
                        `media_files`. The generic
                        `POST /rails/active_storage/direct_uploads` endpoint is
                        disabled (403) and is not a way to obtain one. Subject
                        to the same media rules; any invalid or expired
                        reference fails the whole call with 422.
                      items:
                        type: string
                notification_recipient_group_ids:
                  type: array
                  description: |
                    Notification recipient group ids to send to (top-level, not
                    under `alert`). Business-scoped server-side.
                  items:
                    type: integer
          multipart/form-data:
            schema:
              type: object
              description: 'The SAME body, form-encoded, plus the attachment file
                parts. A file can only be sent this way — `format: binary` inside
                a JSON-only requestBody is something no generated client (and no Swagger
                UI) can actually post. Every other field is sent with its bracketed
                name, e.g. `alert[title]`, `alert[body]`, `alert[status]`, `notification_recipient_group_ids[]`.'
              properties:
                alert[media_files][]:
                  type: array
                  maxItems: 4
                  items:
                    type: string
                    format: binary
                  description: The attachment file parts, uploaded in THIS request.
                    Works with every authentication method, Bearer tokens included.
                    They land in the alert's `media_files` (Drive) — the same attachments
                    the web composer and the show API expose. Validated server-side
                    against the alert media rules (photos/video only, per-file size
                    cap, max 4 files, counted together with `media_signed_ids`); any
                    invalid file fails the whole call with 422 `invalid_attachments`
                    and nothing is persisted.
                alert[media_signed_ids][]:
                  type: array
                  items:
                    type: string
                  description: Attachments by reference — see the JSON body's `media_signed_ids`.
                alert[title]:
                  type: string
                alert[body]:
                  type: string
      responses:
        '201':
          description: |
            Alert created. When `alert.status: "draft"` the alert is **saved as a
            draft** (`status: "draft"`) — not dispatched, not sent for approval.
            Otherwise it is **published**: **dispatched** immediately
            (`status: "sending"`). An enforced approval workflow covering
            emergency alerts never holds it; the send is recorded as an override
            and the workflow's reviewers are told afterwards.
          content:
            application/json:
              schema:
                type: object
                required:
                - alert
                - status
                properties:
                  alert:
                    "$ref": "#/components/schemas/AlertSummary"
                  attachments:
                    type: array
                    description: The attachments that were attached to the alert (from
                      media_files / media_signed_ids).
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                  status:
                    type: string
                    enum:
                    - draft
                    - sending
                    description: |
                      `draft` (saved only) or `sending` (dispatched — an
                      approval workflow never holds an alert).
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Manager access required, or app not enabled
        '422':
          description: Validation failed, or the alert cannot be dispatched as written
            (`dispatch_failed`)
  "/alerts/my":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: The caller's personal received-alert inbox
      description: |
        The API twin of the web My Alerts page (`/apps/safety-hub/alerts/my`): a
        PURE recipient inbox. Returns ONLY alerts that were SENT to the caller
        (they hold an `AlertDelivery` row), in a delivered state
        (sending/sent/completed/cancelled), most recent first.

        Deliberately different from `GET /alerts` (the mobile list): this endpoint
        has NO drafts, never surfaces alerts the caller AUTHORED but was not a
        recipient of, and carries NO manager envelope — there is no
        `pending_approvals`, `can_manage`, or `can_dispatch`. Open to every member,
        exactly like the web page.

        `meta.segment_counts` counts what still NEEDS THE CALLER'S RESPONSE
        (all / acknowledge / safety_check_in) over the received inbox, independent
        of the active `filter`. There is no `draft` segment.
      parameters:
      - name: filter
        in: query
        description: |
          Subfilter. Default `all`.
            * `all`             — every received alert (most recent first)
            * `acknowledge`     — `ack_required = true`
            * `safety_check_in` — `safety_check_in_required = true`
                                  (also accepts `safety` / `safety check in`)
          `draft` is not valid here (this feed has no drafts) and is coerced to
          `all`; the applied value is echoed as `meta.applied_filter`.
        required: false
        schema:
          type: string
          enum:
          - all
          - acknowledge
          - safety_check_in
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: The caller's received alerts, most recent first
          content:
            application/json:
              schema:
                type: object
                required:
                - alerts
                - meta
                properties:
                  alerts:
                    type: array
                    description: Alerts sent to the caller, each with the caller's
                      own response state.
                    items:
                      "$ref": "#/components/schemas/AlertSummary"
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - applied_filter
                    - segment_counts
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      applied_filter:
                        type: string
                        enum:
                        - all
                        - acknowledge
                        - safety_check_in
                        description: The filter actually applied (a `draft` request
                          is coerced to `all`).
                      segment_counts:
                        type: object
                        description: '"Needs my response" counts over the received
                          inbox, independent of `filter`. No `draft` segment (this
                          feed has no drafts).

                          '
                        required:
                        - all
                        - acknowledge
                        - safety_check_in
                        properties:
                          all:
                            type: integer
                          acknowledge:
                            type: integer
                          safety_check_in:
                            type: integer
        '401':
          description: Authentication required
        '403':
          description: Missing the read:broadcasts scope, or the app/module is not
            enabled
  "/alerts/team":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Team Alerts — the manager register of every alert in the business
      description: |
        The API twin of the web Team Alerts page (`/apps/safety-hub/alerts`): the
        manager/admin ops register of EVERY emergency alert in the business.

        Deliberately different from `GET /alerts` and `GET /alerts/my`, which are
        the caller's RECEIVED inbox. This is the tenant-wide management list, so
        it is MANAGER+ gated (a `manager_or_above?` user or the Safety Hub app
        admin — the same tier as the web `authorize_alert_access`) and carries the
        read token ceiling: a `read:own_broadcasts`-only token is refused (403).

        Every alert in the business, most recent first, filterable by lifecycle
        `status` and title/body `q`, paginated. Each row carries the standard
        alert shape plus a `response_summary` (delivery progress), and `meta`
        carries `status_counts` (per-tab totals) and the root-level `can_manage` /
        `can_dispatch` capability flags.
      parameters:
      - name: status
        in: query
        description: |
          Filter by lifecycle status. Default `all` (no filter).
            * `all`       — every status
            * `draft`     — composing, not yet sent
            * `sending`   — dispatch in progress (a transient state)
            * `sent`      — all per-recipient jobs enqueued
            * `completed` — every delivery terminal
            * `cancelled` — aborted
          An unrecognized value is treated as `all`; the applied value is echoed
          as `meta.applied_status`.
        required: false
        schema:
          type: string
          enum:
          - all
          - draft
          - sending
          - sent
          - completed
          - cancelled
          default: all
      - name: q
        in: query
        description: Title/body search (tokenized; matches on word, not order).
        required: false
        schema:
          type: string
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 25, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Every alert in the business, most recent first
          content:
            application/json:
              schema:
                type: object
                required:
                - alerts
                - can_manage
                - can_dispatch
                - meta
                properties:
                  alerts:
                    type: array
                    description: Every alert in the business (for this page), each
                      with a delivery-progress summary.
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/AlertSummary"
                      - type: object
                        properties:
                          response_summary:
                            type: object
                            description: 'Delivery progress for the alert. `responded`
                              / `outstanding` are response-mode aware: for a safety-check-in
                              alert `responded` = safe + needs_help; for an acknowledge
                              alert `responded` = acknowledged; for a plain urgent
                              alert both are null (no response is owed).

                              '
                            properties:
                              recipients:
                                type: integer
                              acknowledged:
                                type: integer
                              safe:
                                type: integer
                              needs_help:
                                type: integer
                              responded:
                                type: integer
                                nullable: true
                              outstanding:
                                type: integer
                                nullable: true
                  can_manage:
                    type: boolean
                    description: Whether the caller can compose and stage emergency
                      alerts (same rule as GET /alerts).
                  can_cancel:
                    type: boolean
                    description: Whether the caller may stand an alert down — cancel
                      it, discard a draft or withdraw its approval request. `can_manage`
                      AND a credential holding destructive:safety_hub, which those
                      doors ask (a signed-in session always does).
                  can_dispatch:
                    type: boolean
                    description: Whether the caller may DISPATCH an alert — `can_cancel`
                      AND the narrower per-tenant policy.
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    - applied_status
                    - status_counts
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      applied_status:
                        type: string
                        enum:
                        - all
                        - draft
                        - sending
                        - sent
                        - completed
                        - cancelled
                        description: The status actually applied (an unrecognized
                          request is coerced to `all`).
                      status_counts:
                        type: object
                        description: 'Per-tab totals over the whole business + search
                          base, INDEPENDENT of the active `status` filter, so a filter-row
                          badge always shows its true total. `all` is the sum.

                          '
                        required:
                        - all
                        - draft
                        - sending
                        - sent
                        - completed
                        - cancelled
                        properties:
                          all:
                            type: integer
                          draft:
                            type: integer
                          sending:
                            type: integer
                          sent:
                            type: integer
                          completed:
                            type: integer
                          cancelled:
                            type: integer
        '401':
          description: Authentication required
        '403':
          description: Not a manager, or the token is scoped to own alerts (needs
            read:broadcasts), or the app/module is not enabled
  "/alerts/templates":
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: List alert templates for the composer's "Start from a template" picker
      description: |
        Powers the alert composer's "Start from a template" picker (opened from
        the alert list `+`). Returns the SAME two groups the web composer offers:
          * `your_templates`   — this business's saved alert templates (ordered)
          * `common_scenarios` — the platform-curated common scenarios
                                 (system templates, shared across businesses),
                                 so an author can start from a pre-approved
                                 emergency scenario.
        Each entry carries the composition fields the client pre-fills the
        new-alert form with (title / body / sms_body / channels + the urgency /
        ack / safety toggles). Requires **manager or above** — the same gate as
        creating an alert (`authorize_alert_access` → `manager_or_above?`), so
        anyone who can create an alert can fetch the templates to start from.
      responses:
        '200':
          description: Templates listed
          content:
            application/json:
              schema:
                type: object
                required:
                - your_templates
                - common_scenarios
                - meta
                properties:
                  your_templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AlertTemplate"
                  common_scenarios:
                    type: array
                    items:
                      "$ref": "#/components/schemas/AlertTemplate"
                  meta:
                    type: object
                    required:
                    - your_templates_count
                    - common_scenarios_count
                    properties:
                      your_templates_count:
                        type: integer
                      common_scenarios_count:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required, or the app is not enabled
  "/alerts/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Get an alert
      description: |
        Returns the full alert plus its file `attachments` (the alert's
        media_files — photos/videos uploaded with the alert, the same Drive
        attachments the web alert detail renders).

        For a DRAFT (unpublished) alert, also carries the approval-pipeline
        fields (`approval_state`, `approval_id`, `approval_decided_by`,
        `approval_decided_at`, `approval_notes`) — the same shape the
        `?filter=draft` list returns per item. These reflect the alert's latest
        Comms Hub approval request. They are `null` once the alert is published
        (sent/scheduled), for a draft that never entered the approval pipeline,
        or for a caller who may not see the alert's internal review trail (a
        non-author, non-privileged viewer — the same gate as `can_view_tracking`).
      responses:
        '200':
          description: Alert found
          content:
            application/json:
              schema:
                type: object
                required:
                - alert
                properties:
                  alert:
                    allOf:
                    - "$ref": "#/components/schemas/AlertSummary"
                    - type: object
                      properties:
                        acknowledged:
                          type: boolean
                          description: 'Whether the calling user has acknowledged
                            this alert. Forced to true when the caller is the alert''s
                            author and is NOT in its audience (no delivery / not a
                            recipient) — they have nothing to acknowledge, so the
                            detail screen doesn''t strand an unacknowledged state
                            they can''t clear.

                            '
                        can_manage:
                          type: boolean
                          description: 'The root `can_manage` of GET /alerts — whether
                            the caller may compose and stage alerts — NOT this alert''s
                            tracking gate: draw the "Tracking Details" affordance
                            from `can_view_tracking`, which matches GET /alerts/{id}/acknowledgements,
                            /check_ins and /recipients.

                            '
                        can_cancel:
                          type: boolean
                          description: The root `can_cancel` of GET /alerts.
                        can_dispatch:
                          type: boolean
                          description: The root `can_dispatch` of GET /alerts.
                        can_remind_unresponded:
                          type: boolean
                          description: 'Sender''s side only. True when POST /alerts/{id}/remind_unresponded
                            would send now — the caller manages alerts, tracks this
                            one, may send alerts, holds write:broadcasts and destructive:safety_hub,
                            and nothing refuses it (the 15-minute wait included).

                            '
                        remind_unresponded_refusal:
                          type: string
                          nullable: true
                          description: 'Why Remind is not offered right now (e.g.
                            a reminder went out less than 15 minutes ago), for a disabled
                            button with its reason; null when it is offered or the
                            caller could not press it anyway.

                            '
                        screens:
                          type: object
                          nullable: true
                          description: 'Whether Digital Signage break-room screens
                            carry this alert — the web alert page''s "Break-room screens"
                            row, from the rule the screens play by. Returned to the
                            sender''s side only (the same gate as `audience`); `null`
                            for a recipient, for an alert that never went out, or
                            for a business with no active screen.

                            '
                          properties:
                            state:
                              type: string
                              enum:
                              - showing
                              - no_screen_now
                              - not_for_everyone
                              - sample
                              - stood_down
                              - ended
                              - displaced
                              description: "`showing` — on at least one screen now;
                                `no_screen_now` — still eligible, but every screen
                                has Critical alerts off or out of schedule, or a full
                                Max items; `not_for_everyone` — screens show only
                                alerts sent to everyone; `sample` — sample alerts
                                never play; `stood_down`; `ended` — its time on screens
                                is over; `displaced` — newer alerts hold the screens'
                                places for now (it comes back if they leave before
                                `until`).\n"
                            screens:
                              type: integer
                              description: Active screens in the business.
                            showing_on:
                              type: integer
                              description: Screens showing it now (0 unless `showing`).
                            showing_on_sample:
                              type: integer
                              description: How many of `showing_on` are sample screens
                                (from "Load sample data") — they really play the business's
                                alerts.
                            critical_alerts_off:
                              type: integer
                              description: Screens where alerts are switched off right
                                now — the screen's Critical alerts setting, its schedule,
                                or the app's default (counted for `showing` and `no_screen_now`;
                                0 otherwise).
                            max_items_full:
                              type: integer
                              description: Screens whose Max items other alerts already
                                fill — urgent alerts of the last 24 hours first, then
                                the newest (counted for `showing` and `no_screen_now`;
                                0 otherwise).
                            showing_on_not_checked_in:
                              type: integer
                              description: How many of `showing_on` have not checked
                                in within the online window (the screen's last check-in
                                is older than 5 minutes), or whose display URL was
                                replaced with no device on the new one yet — they
                                show it once they check in, until `until`.
                            until:
                              type: string
                              format: date-time
                              nullable: true
                              description: 'When screens let it go — set for `showing`,
                                `no_screen_now`, `displaced` (how long it can come
                                back) and `ended` (in the past once `ended`); `null`
                                in the other states, which its clock does not govern.

                                '
                        attachments:
                          type: array
                          description: The alert's file attachments (DriveItem media).
                          items:
                            type: object
                            required:
                            - id
                            - filename
                            properties:
                              id:
                                type: integer
                              filename:
                                type: string
                              content_type:
                                type: string
                                nullable: true
                              byte_size:
                                type: integer
                              url:
                                type: string
                                description: ActiveStorage blob URL (attachment disposition).
        '401':
          description: Authentication required
        '404':
          description: Alert not found
    patch:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Edit a draft alert (manager+)
      description: |
        Edit a **DRAFT** alert. The body is the **same shape as `POST /alerts`**
        (the `alert[...]` fields + `notification_recipient_group_ids` +
        `alert[media_files][]` / `alert[media_signed_ids]` + the `alert[status]` disposition). PATCH
        semantics: only the fields you send are changed — an omitted field is
        left as-is, and audience targeting is replaced only when you send
        targeting params. `alert[media_files][]` and `alert[media_signed_ids]` are ADDED to the draft
        (already-attached media is untouched).

        Like create, `alert[status]` chooses the disposition: `"publish"` sends
        the alert now (dispatch — an approval workflow never holds an alert) —
        the response then carries `status: "sending"`; any other value (incl.
        absent) keeps it a
        **draft**. So a client can edit-and-publish in one call.

        Constraints (mirror the web AlertsController#update): only a **draft** is
        editable (`422 not_a_draft` otherwise), and a draft with a **pending
        approval request** must have it withdrawn first (`422 approval_pending`) —
        except a publish-only call (`alert[status]` = `publish` and no other
        field), which sends the alert as it is, like `send_now`.
        Requires **manager or above**.

        Publishing (`alert[status]` = `publish`) sends the alert, which is
        delete-level (owner decision 2026-10-04): that request also requires
        `destructive:safety_hub`. A draft needs only `write:broadcasts`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - alert
              properties:
                alert:
                  type: object
                  description: |
                    Same permitted fields as `POST /alerts`. All are optional on
                    edit — send only what changes.
                  properties:
                    status:
                      type: string
                      enum:
                      - draft
                      - publish
                      description: Disposition — `"publish"` sends; anything else
                        keeps it a draft.
                    title:
                      type: string
                    body:
                      type: string
                    sms_body:
                      type: string
                    action_url:
                      type: string
                    urgent:
                      type: boolean
                    ack_required:
                      type: boolean
                    safety_check_in_required:
                      type: boolean
                    audience_id:
                      type: integer
                      nullable: true
                    channels:
                      type: array
                      items:
                        type: string
                    extra_user_ids:
                      type: array
                      items:
                        type: integer
                      description: Specific user ids (replaces the draft's manual
                        recipient list when sent).
                    audience_criteria:
                      type: array
                      description: Attribute-based audience filters (same typed hashes
                        as create).
                      items:
                        type: object
                        additionalProperties: true
                    media_signed_ids:
                      type: array
                      items:
                        type: string
                      description: ActiveStorage blob signed_ids the client already
                        holds, to ADD to the draft. Prefer the multipart file parts.
                notification_recipient_group_ids:
                  type: array
                  items:
                    type: integer
                  description: Notification recipient group ids (top-level; replaces
                    the draft's groups when sent).
          multipart/form-data:
            schema:
              type: object
              description: The same edit, form-encoded, plus the attachment file parts
                — the only content type a file can actually be sent on. Every other
                field takes its bracketed name, e.g. `alert[title]`, `alert[status]`.
              properties:
                alert[media_files][]:
                  type: array
                  maxItems: 4
                  items:
                    type: string
                    format: binary
                  description: File parts to ADD to the draft. Counted with the files
                    the draft already holds against the 4-file limit; an invalid file
                    fails the edit with 422 `invalid_attachments` and nothing is changed.
                alert[media_signed_ids][]:
                  type: array
                  items:
                    type: string
                  description: Attachments by reference to ADD to the draft. Prefer
                    the file parts.
      responses:
        '200':
          description: |
            Draft edited. `status: "draft"` when kept as a draft; `"sending"` when
            published (dispatched — an enforced approval workflow records the send
            as an override rather than holding it).
          content:
            application/json:
              schema:
                type: object
                required:
                - alert
                - status
                properties:
                  alert:
                    "$ref": "#/components/schemas/AlertSummary"
                  attachments:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                  status:
                    type: string
                    enum:
                    - draft
                    - sending
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Manager access required to manage emergency alerts
        '404':
          description: Alert not found
        '422':
          description: |
            The alert is not a draft (`not_a_draft`), has a pending approval
            request that must be withdrawn before an edit (`approval_pending`), failed
            validation (`validation_failed`), had an invalid attachment
            (`invalid_attachments`), or couldn't be dispatched (`dispatch_failed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - not_a_draft
                        - approval_pending
                        - validation_failed
                        - invalid_attachments
                        - dispatch_failed
    delete:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Discard a draft alert (manager+)
      description: |
        Permanently deletes a DRAFT alert — mirrors the web "Discard draft"
        action (AlertsController#destroy). Only a draft can be discarded: once an
        alert has been sent (or is sending/cancelled) it is part of the delivery
        record and is retained. A draft awaiting approval must have its approval
        request withdrawn first, so a pending CommsHub::ApprovalRequest is never
        orphaned. Manager+ gated (same tier as create).

        Delete-level (owner decision 2026-10-04): requires the `destructive:safety_hub`
        scope.
      responses:
        '200':
          description: Draft discarded
          content:
            application/json:
              schema:
                type: object
                required:
                - alert_id
                - status
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    example: discarded
                  message:
                    type: string
                    example: Draft discarded.
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
        '422':
          description: 'The alert is not a draft (error code `not_a_draft`), is awaiting
            approval and must be withdrawn first (`approval_pending`), or could not
            be discarded (`discard_failed`).

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - not_a_draft
                        - approval_pending
                        - discard_failed
                      message:
                        type: string
  "/alerts/{id}/send_now":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Dispatch a draft alert now (manager+)
      description: |
        Flips a draft alert to `sending` and enqueues the dispatch fan-out. Manager-and-above only. An emergency alert is never held for approval: when an enforced approval workflow covers emergency alerts and the caller is not an admin, the alert still dispatches, the override is recorded on its approval request (an open request closes as `overridden`) and the workflow's reviewers are told afterwards.
        Delete-level (owner decision 2026-10-04): requires the `destructive:safety_hub` scope.
      responses:
        '200':
          description: Alert enqueued
        '403':
          description: Manager access required, or the alert is already dispatched
        '404':
          description: Alert not found
        '422':
          description: 'The alert cannot be dispatched as written (error code `dispatch_failed`,
            e.g. template placeholders still unfilled).

            '
  "/alerts/{id}/remind_unresponded":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Remind everyone who has not checked in (the muster board's Remind)
      description: |
        Re-sends a live safety check-in to everyone who has not answered, as the
        web muster board's "Remind who hasn't answered" does. Only a manager or
        Safety Hub app admin who is the alert's sender or an alert admin and may
        also send alerts (the tenant's "who can send alerts" rule), on a token
        that may write alerts (`write:broadcasts`) and Send and Cancel
        (`destructive:safety_hub`). The model's refusals apply, checked again
        under the row lock: the alert must be live and asking for check-ins,
        there must be a reminder channel, and at most one reminder every 15
        minutes. The alert detail's `can_remind_unresponded` /
        `remind_unresponded_refusal` say beforehand whether it would be sent.
      responses:
        '200':
          description: Reminder queued (or nobody left to remind — `reminded_count`
            0).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  reminded_count:
                    type: integer
                    example: 4
                  channels:
                    type: array
                    items:
                      type: string
                    example:
                    - in_app
                    - push
                    - sms
                  message:
                    type: string
                    example: Reminding 4 people who have not checked in — in the app,
                      by push, and by text.
        '403':
          description: Not a manager or Safety Hub app admin, not the alert's sender
            or an alert admin, not allowed to send alerts, or the token lacks write:broadcasts
            or destructive:safety_hub.
        '404':
          description: Alert not found
        '422':
          description: Refused (`remind_refused`) — the message is the reason, e.g.
            a reminder went out less than 15 minutes ago.
  "/alerts/{id}/cancel":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Cancel a draft, sending, sent or completed alert (manager and above)
      description: |
        Delete-level (owner decision 2026-10-04): requires the `destructive:safety_hub`
        scope.
      responses:
        '200':
          description: Alert cancelled
        '403':
          description: Manager access required, or alert cannot be cancelled
        '404':
          description: Alert not found
  "/alerts/{id}/submit_for_approval":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Submit a draft alert for approval (manager+)
      description: 'Submits a DRAFT alert for review against the governing enforced
        approval workflow for emergency alerts — the API mirror of the web AlertsController#submit_for_approval,
        reusing the shared CommsHub::ApprovalEnforceable#submit_source_for_approval.
        Manager+ (same gate as create). The inverse of withdraw_approval. Reviewers
        are notified.

        '
      responses:
        '200':
          description: Alert submitted for approval (a pending request now exists;
            the alert stays draft)
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    description: Always "pending_approval" on success.
                  message:
                    type: string
        '403':
          description: Manager access required (or the Broadcasts & Alerts app is
            not enabled)
        '404':
          description: Alert not found
        '422':
          description: 'Cannot submit — the alert is not a draft (not_a_draft), no
            workflow governs emergency alerts (no_workflow), a request is already
            pending (already_pending), or the submit otherwise failed (submit_failed
            / no_item).

            '
  "/alerts/{id}/withdraw_approval":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Withdraw a pending approval request (manager+)
      description: |
        Pulls back the alert's pending Comms Hub approval request so the author can edit and resubmit — mirrors the web AlertsController#withdraw_approval. Manager+ at the controller (same gate as create); the model additionally allows only the ORIGINAL REQUESTER or an admin to actually withdraw, so a manager who did not submit it (and is not an admin) gets 422.
        Delete-level (owner decision 2026-10-04): requires the `destructive:safety_hub` scope.
      responses:
        '200':
          description: Approval request withdrawn (the alert returns to draft, editable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    description: The alert's status after withdrawal (draft).
                  message:
                    type: string
        '403':
          description: Manager access required (or the Broadcasts & Alerts app is
            not enabled)
        '404':
          description: Alert not found, or there is no pending approval request to
            withdraw
        '422':
          description: Could not withdraw (e.g. the caller is neither the original
            requester nor an admin)
  "/alerts/{id}/check_in":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Record a safety check-in response (recipient)
      description: |
        Records the caller's safety check-in response, mirroring the web
        AlertsController#check_in_submit. The caller must be a **recipient** of
        the alert (have an `AlertDelivery` row) — otherwise `403`.

        Idempotent upsert: a re-submit **updates** the caller's existing response,
        so a recipient can change it (e.g. from `needs_help` to `safe`). The
        `note` (situation details) is captured on the `needs_help` path and
        **cleared** when the recipient updates back to `safe`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - safe
                  - needs_help
                note:
                  type: string
                  nullable: true
                  description: |
                    Optional situation details. Stored only when `status` is
                    `needs_help`; ignored / cleared when `status` is `safe`.
      responses:
        '200':
          description: Check-in recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - alert_id
                - status
                - message
                properties:
                  alert_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - safe
                    - needs_help
                  note:
                    type: string
                    nullable: true
                    description: The stored situation note (null for a safe response).
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Not a recipient of this alert
        '404':
          description: Alert not found
        '422':
          description: Invalid status
  "/alerts/{id}/acknowledge":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    post:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Acknowledge an ack-required alert (recipient)
      description: |
        Records the caller's explicit read-confirmation for an acknowledge-type
        alert, mirroring the web AlertsController#acknowledge. **Recipient-gated**
        — the caller must be a recipient of the alert (have an `AlertDelivery`
        row); otherwise `403`. **Idempotent**: re-acknowledging is a no-op success
        that preserves the original `acknowledged_at`.
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                required:
                - alert_id
                - acknowledged
                properties:
                  alert_id:
                    type: integer
                  acknowledged:
                    type: boolean
                  acknowledged_at:
                    type: string
                    format: date-time
                    nullable: true
                  message:
                    type: string
        '401':
          description: Authentication required
        '403':
          description: Not a recipient of this alert (`not_a_recipient`)
        '404':
          description: Alert not found
  "/alerts/{id}/check_ins":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Safety check-in tracking roster (Tracking Details)
      description: |
        Powers the web **Tracking Details** screen for a safety-check-in alert:
        the alert's recipients (everyone it was delivered to) with their safety
        response, plus the safe / needs-help / unresponded counts.

        **Manager or above** — mirrors the web alert tracking page authorization
        (`authorize_alert_access` → `manager_or_above?`), same as the other
        tracking rosters. Only meaningful for an alert with
        `safety_check_in_required = true` (others return `422`).

        Each row carries the responder's `status` (`safe` / `needs_help` /
        `unresponded`), `responded_at`, and `note` (the situation details a
        "needs help" responder left). The recipient set is scoped to the
        business's users; rows are ordered by responder name (case-insensitive,
        with user id as a unique tiebreaker so paging cannot repeat or skip a
        recipient) — 20/page by default. Use `status` for the prioritized tabs.
      parameters:
      - name: status
        in: query
        description: |
          Roster filter. Default `all`.
            * `all`         — every recipient, each annotated with their status
            * `safe`        — recipients who marked themselves safe
            * `needs_help`  — recipients who responded "needs help"
            * `unresponded` — recipients who have not responded yet
        required: false
        schema:
          type: string
          enum:
          - all
          - safe
          - needs_help
          - unresponded
          default: all
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed with their safety response, plus counts
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - status
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (profile photo variant,
                            else a ui-avatars fallback).
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        status:
                          type: string
                          enum:
                          - safe
                          - needs_help
                          - unresponded
                        responded_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the recipient responded (null when unresponded).
                        note:
                          type: string
                          nullable: true
                          description: Situation details left by a "needs help" responder
                            (null otherwise).
                  meta:
                    type: object
                    required:
                    - status
                    - total_recipients_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      status:
                        type: string
                        description: The resolved roster filter (all | safe | needs_help
                          | unresponded).
                      total_recipients_count:
                        type: integer
                        description: All recipients the alert was delivered to.
                      safe_count:
                        type: integer
                        description: Recipients who marked themselves safe. Included
                          only on the first page (page == 1).
                      need_help_count:
                        type: integer
                        description: Recipients who responded "needs help". Included
                          only on the first page (page == 1).
                      unresponded_count:
                        type: integer
                        description: Recipients who have not responded yet. Included
                          only on the first page (page == 1).
                      total_count:
                        type: integer
                        description: Items matching the active status filter (this
                          list's size).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
        '422':
          description: Alert does not require a safety check-in (`not_safety_check_in`)
  "/alerts/{id}/acknowledgements":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Acknowledgement tracking roster (Tracking Details)
      description: |
        Powers the web **Tracking Details** screen for an ACK-REQUIRED alert:
        the alert's recipients (everyone it was delivered to) filtered by whether
        they acknowledged, plus the ack / non-ack counts.

        **Manager or above** — mirrors the web alert tracking page authorization
        (`authorize_alert_access` → `manager_or_above?`). Only meaningful for an
        alert with `ack_required = true` (others return `422`).

        Each acked row carries the `acknowledged_at` date the screen shows
        (null on the `not_acked` tab). The recipient set is scoped to the
        business's users. The `acked` tab is ordered by acknowledgement time
        (newest first) with user id as a unique tiebreaker; the `not_acked` tab
        is ordered by user id. 20/page by default.
      parameters:
      - name: type
        in: query
        description: |
          Roster filter. Default `acked`.
            * `acked`     — recipients who HAVE acknowledged (with acknowledged_at)
            * `not_acked` — recipients who have NOT acknowledged
        required: false
        schema:
          type: string
          enum:
          - acked
          - not_acked
          default: acked
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed with acknowledgement state, plus counts
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (profile photo variant,
                            else a ui-avatars fallback).
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the recipient acknowledged (null on the
                            not_acked tab).
                  meta:
                    type: object
                    required:
                    - type
                    - total_recipients_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      type:
                        type: string
                        description: The resolved roster filter (acked | not_acked).
                      total_recipients_count:
                        type: integer
                        description: All recipients the alert was delivered to.
                      ack_count:
                        type: integer
                        description: Recipients who have acknowledged. Included only
                          on the first page (page == 1).
                      non_acked_count:
                        type: integer
                        description: Recipients who have not acknowledged. Included
                          only on the first page (page == 1).
                      total_count:
                        type: integer
                        description: Items matching the active type filter (this list's
                          size).
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
        '422':
          description: Alert does not require acknowledgment (`not_ack_required`)
  "/alerts/{id}/recipients":
    parameters:
    - name: id
      in: path
      required: true
      description: Alert ID
      schema:
        type: integer
    get:
      tags:
      - Alerts
      security:
      - BearerAuth: []
      summary: Alert delivery roster (Tracking Details — Recipients)
      description: |
        Powers the **Recipients** section of the web "Tracking Details" screen —
        the only tracking section present for a plain urgent alert (neither
        acknowledge- nor safety-check-in-required), and also available for the
        other alert types.

        Returns every recipient (everyone the alert was delivered to) with the
        **channels they were reached on** (in app / email / push / sms / voice,
        each with delivery status + time). **Manager+ only** (mirrors the web
        alerts#show authorize_alert_access gate).

        The recipient set is scoped to the business's users; rows are ordered by
        user id for stable pagination (20/page by default). `meta.channel_counts`
        is the per-channel reach (distinct recipients reached on each channel) —
        the per-channel summary the screen shows — and
        `meta.channel_delivery_counts` is the same roster split by delivery
        outcome (`sent`, `skipped`, `failed`, `pending`, every key always
        present) for a "N sent · 0 skipped · 0 failed · 0 pending" line. Both
        are roster-wide aggregates returned **only on page 1** (the client
        renders them once in the header); they are omitted on subsequent pages.
      parameters:
      - name: page
        in: query
        description: Page number (default 1).
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Recipients listed with the channels they were reached on
          content:
            application/json:
              schema:
                type: object
                required:
                - recipients
                - meta
                properties:
                  recipients:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - channels
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                          description: Absolute avatar URL (profile photo variant,
                            else a ui-avatars fallback).
                        title:
                          type: string
                          nullable: true
                          description: Recipient's job title (row subtitle).
                        email:
                          type: string
                          nullable: true
                          description: Recipient's email (row subtitle).
                        channels:
                          type: array
                          description: The channels this recipient was reached on.
                          items:
                            type: object
                            required:
                            - channel
                            properties:
                              channel:
                                type: string
                                description: Delivery channel (e.g. in_app, email,
                                  push, sms, voice).
                              status:
                                type: string
                                nullable: true
                                description: Per-channel delivery status (e.g. sent,
                                  failed, skipped).
                              delivered_at:
                                type: string
                                format: date-time
                                nullable: true
                              fallback_language:
                                type: string
                                nullable: true
                                description: The recipient's language this channel
                                  went out WITHOUT — it went out in the original language
                                  because the alert's translation into it was not
                                  ready yet, or translation is turned off. Null when
                                  it went out in the recipient's language, and always
                                  null for email (written in the recipient's language
                                  when it is sent).
                              translated_followup_at:
                                type: string
                                format: date-time
                                nullable: true
                                description: When this text or mobile-app push was
                                  sent again in the recipient's language, because
                                  the translation was ready within 5 minutes of Send.
                                  Null when no follow-up went out.
                  meta:
                    type: object
                    required:
                    - total_recipients_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      total_recipients_count:
                        type: integer
                        description: All recipients the alert was delivered to.
                      channel_counts:
                        type: object
                        description: |
                          Distinct recipients reached per channel, keyed by channel name.
                          Returned **only on page 1** (roster-wide aggregate); omitted on
                          subsequent pages.
                        additionalProperties:
                          type: integer
                      channel_delivery_counts:
                        type: object
                        description: |
                          Distinct recipients per channel split by delivery outcome, keyed
                          by channel name. Every status key is present (0 when none), so a
                          client can print "N sent · 0 skipped · 0 failed · 0 pending".
                          Returned **only on page 1**; omitted on subsequent pages.
                        additionalProperties:
                          type: object
                          properties:
                            sent:
                              type: integer
                            skipped:
                              type: integer
                            failed:
                              type: integer
                            pending:
                              type: integer
                          required:
                          - sent
                          - skipped
                          - failed
                          - pending
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Authentication required
        '403':
          description: Manager access required
        '404':
          description: Alert not found
  "/compensation/profile":
    get:
      tags:
      - Employee Compensation
      summary: Get employee compensation profile
      description: |
        Retrieves the current employee's compensation profile including current compensation details,
        policies, and eligibility information. Respects organization compensation policies for employee self-view.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Employee compensation profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_profile:
                    type: object
                    description: Employee compensation profile with current details
                      and policies
                    properties:
                      id:
                        type: integer
                        description: User business ID
                        example: 123
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          title:
                            type: string
                            nullable: true
                            example: Software Engineer
                          hire_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2023-01-15'
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            enum:
                            - salary
                            - hourly
                            - commission
                            - contract
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example: 36.06
                          currency:
                            type: string
                            example: USD
                          pay_frequency:
                            type: string
                            enum:
                            - weekly
                            - biweekly
                            - monthly
                            - quarterly
                            - annually
                            example: monthly
                          effective_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-01'
                      policies:
                        type: object
                        properties:
                          can_view_details:
                            type: boolean
                            example: true
                          can_request_changes:
                            type: boolean
                            example: true
                          request_frequency:
                            type: string
                            enum:
                            - annual
                            - semi_annual
                            - unlimited
                            example: annual
                          mobile_access_enabled:
                            type: boolean
                            example: true
                      next_review_eligible:
                        type: string
                        format: date
                        nullable: true
                        description: The next compensation review date your organization
                          has set; null when none is set.
                        example: '2024-12-01'
                      last_updated:
                        type: object
                        description: The most recent compensation change on record;
                          both fields are null when there is none.
                        properties:
                          date:
                            type: string
                            format: date-time
                            nullable: true
                            description: When the change was recorded.
                            example: '2024-01-15T10:30:00Z'
                          by:
                            type: string
                            nullable: true
                            description: Who made the change, or "System" for a change
                              recorded with no author (an automated step increase
                              or import).
                            example: Jane Smith
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/compensation/history":
    get:
      tags:
      - Employee Compensation
      summary: Get compensation change history
      description: |
        Retrieves the employee's compensation change history with pagination and filtering options.
        Shows historical compensation changes, reasons, and approval information.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: |
          Number of items per page. Values above 100 are clamped to 100;
          blank, non-numeric and non-positive values fall back to the default.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      - name: from_date
        in: query
        description: |
          Filter changes on or after this date (YYYY-MM-DD). Inclusive — the
          bound is midnight (UTC) at the start of the day.
        schema:
          type: string
          format: date
      - name: to_date
        in: query
        description: |
          Filter changes on or before this date (YYYY-MM-DD). Inclusive of the
          WHOLE day — the bound is 23:59:59.999999 (UTC).
          filter-search-audit 2026-09-02: the description already read as
          inclusive while the query bound was `change_date <= <midnight>`, which
          excluded every change recorded later that day (2,162 of 2,168 rows
          carry a non-midnight time). The scope was fixed to match this wording,
          not the other way round.
        schema:
          type: string
          format: date
      responses:
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '200':
          description: Compensation history retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Individual compensation change record
                        properties:
                          id:
                            type: integer
                            example: 789
                          change_date:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:30:00Z'
                          effective_date:
                            type: string
                            format: date
                            example: '2024-02-01'
                          change_reason:
                            type: string
                            example: Annual merit increase
                          changed_by:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 101
                              name:
                                type: string
                                example: Jane Manager
                          changes:
                            type: object
                            properties:
                              compensation_type:
                                type: object
                                properties:
                                  from:
                                    type: string
                                    nullable: true
                                    example: salary
                                  to:
                                    type: string
                                    nullable: true
                                    example: salary
                              annual_salary:
                                type: object
                                properties:
                                  from:
                                    type: number
                                    nullable: true
                                    example: 70000.0
                                  to:
                                    type: number
                                    nullable: true
                                    example: 75000.0
                              hourly_rate:
                                type: object
                                properties:
                                  from:
                                    type: number
                                    nullable: true
                                    example:
                                  to:
                                    type: number
                                    nullable: true
                                    example:
                              pay_frequency:
                                type: object
                                properties:
                                  from:
                                    type: string
                                    nullable: true
                                    example: monthly
                                  to:
                                    type: string
                                    nullable: true
                                    example: monthly
                              currency:
                                type: object
                                properties:
                                  from:
                                    type: string
                                    nullable: true
                                    example: USD
                                  to:
                                    type: string
                                    nullable: true
                                    example: USD
                          change_summary:
                            type: string
                            example: 'Salary: $70,000 → $75,000'
                          change_direction:
                            type: string
                            enum:
                            - increase
                            - decrease
                            - neutral
                            example: increase
                          is_retroactive:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/CollectionPaginationMeta"
                      - type: object
                        properties:
                          total_changes:
                            type: integer
                            description: Compensation changes matching the date filters
                              (same as `total_count`).
                          date_range:
                            type: object
                            description: The `from_date` / `to_date` filters as sent
                              (null when absent).
                            properties:
                              from:
                                type: string
                                nullable: true
                                example: '2026-01-01'
                              to:
                                type: string
                                nullable: true
        '304':
          "$ref": "#/components/responses/NotModified"
  "/compensation/insights":
    get:
      tags:
      - Employee Compensation
      summary: Get personalized compensation insights
      description: |
        Provides personalized compensation insights including tenure analysis, recent activity,
        request eligibility, and performance integration information.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Compensation insights retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  insights:
                    type: object
                    description: Personalized compensation insights and analytics
                    properties:
                      summary:
                        type: object
                        properties:
                          current_annualized_value:
                            type: number
                            nullable: true
                            example: 75000.0
                          compensation_type:
                            type: string
                            example: salary
                          currency:
                            type: string
                            example: USD
                          tenure_months:
                            type: integer
                            example: 18
                      recent_activity:
                        type: object
                        properties:
                          changes_last_12_months:
                            type: integer
                            example: 1
                          last_change_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-15'
                      request_eligibility:
                        type: object
                        properties:
                          can_request_now:
                            type: boolean
                            example: true
                          next_eligible_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                      performance_link:
                        type: object
                        description: Not tracked yet — both fields are always null.
                        properties:
                          has_recent_review:
                            type: boolean
                            nullable: true
                            description: Not tracked yet; always null.
                            example:
                          merit_increase_eligible:
                            type: boolean
                            nullable: true
                            description: Not tracked yet; always null.
                            example:
  "/compensation/calculate":
    post:
      tags:
      - Employee Compensation
      summary: Calculate compensation conversions
      description: |
        Performs real-time compensation calculations including salary to hourly conversions
        and vice versa based on configurable annual hours.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - calculation
              properties:
                calculation:
                  type: object
                  required:
                  - compensation_type
                  properties:
                    compensation_type:
                      type: string
                      enum:
                      - salary
                      - hourly
                      description: Type of compensation to calculate
                    annual_salary:
                      type: number
                      minimum: 0
                      description: Annual salary amount (required for salary type)
                    hourly_rate:
                      type: number
                      minimum: 0
                      description: Hourly rate amount (required for hourly type)
                    annual_hours:
                      type: number
                      minimum: 1
                      default: 2080
                      description: Annual working hours for calculation basis
                    currency:
                      type: string
                      default: USD
                      description: Currency code
      responses:
        '200':
          description: Calculation completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  calculation:
                    type: object
                    description: Compensation calculation result
                    properties:
                      compensation_type:
                        type: string
                        enum:
                        - salary
                        - hourly
                        example: salary
                      annual_salary:
                        type: number
                        nullable: true
                        example: 75000.0
                      calculated_hourly_rate:
                        type: number
                        nullable: true
                        example: 36.06
                      hourly_rate:
                        type: number
                        nullable: true
                        example:
                      calculated_annual_salary:
                        type: number
                        nullable: true
                        example:
                      annual_hours_basis:
                        type: number
                        example: 2080.0
                      currency:
                        type: string
                        example: USD
  "/compensation/requests":
    get:
      tags:
      - Compensation Requests
      summary: List employee compensation requests
      description: |
        Retrieves all compensation change requests submitted by the current employee
        with filtering and pagination options.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        description: |
          Number of items per page. Values above 100 are clamped to 100;
          blank, non-numeric and non-positive values fall back to the default.
        schema:
          type: integer
          default: 10
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: |
          Filter by request status. Case-sensitive, and a single scalar value.
          filter-search-audit 2026-09-02: an unrecognized value used to be
          SILENTLY IGNORED, returning every status under an unfiltered
          meta.total_count (?status=PENDING and ?status=garbage each returned
          all 170 rows for a user whose pending count is 10). It now answers
          400 invalid_status, which this path did not previously declare.
        schema:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - cancelled
      responses:
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '200':
          description: Compensation requests retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Summary view of compensation request
                        properties:
                          id:
                            type: integer
                            example: 456
                          status:
                            type: string
                            enum:
                            - pending
                            - approved
                            - rejected
                            - cancelled
                            example: pending
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-20T14:30:00Z'
                          effective_date:
                            type: string
                            format: date
                            example: '2024-03-01'
                          change_summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          change_impact:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - increase
                                - decrease
                                - neutral
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                nullable: true
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/CollectionPaginationMeta"
                      - type: object
                        properties:
                          statistics:
                            type: object
                            description: The caller's own requests by approval status,
                              across every page and ignoring the `status` filter.
                            properties:
                              total:
                                type: integer
                              pending:
                                type: integer
                              approved:
                                type: integer
                              rejected:
                                type: integer
                              cancelled:
                                type: integer
                          can_create_new:
                            type: boolean
                            description: Whether the caller may submit a new compensation
                              change request now.
        '304':
          "$ref": "#/components/responses/NotModified"
    post:
      tags:
      - Compensation Requests
      summary: Create compensation change request
      description: |
        Creates a new compensation change request. Validates against organization policies
        including request frequency limits and approval workflows.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - compensation_request
              properties:
                compensation_request:
                  type: object
                  description: Input schema for creating/updating compensation requests
                  required:
                  - justification
                  - requested_compensation_type
                  properties:
                    justification:
                      type: string
                      minLength: 10
                      maxLength: 1000
                      example: Requesting salary increase based on performance review
                        and market analysis
                    effective_date:
                      type: string
                      format: date
                      example: '2024-03-01'
                    requested_compensation_type:
                      type: string
                      enum:
                      - salary
                      - hourly
                      - commission
                      - contract
                      example: salary
                    requested_annual_salary:
                      type: number
                      minimum: 0
                      nullable: true
                      example: 80000.0
                    requested_hourly_rate:
                      type: number
                      minimum: 0
                      nullable: true
                      example:
                    requested_pay_frequency:
                      type: string
                      enum:
                      - weekly
                      - biweekly
                      - monthly
                      - quarterly
                      - annually
                      example: monthly
                    requested_currency:
                      type: string
                      example: USD
      responses:
        '201':
          description: Compensation request created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '429':
          description: Too many requests - rate limit exceeded
          headers:
            Retry-After:
              description: Seconds until rate limit reset
              schema:
                type: integer
            X-RateLimit-Limit:
              description: Total requests allowed per window
              schema:
                type: integer
            X-RateLimit-Remaining:
              description: Requests remaining in current window
              schema:
                type: integer
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/compensation/requests/{id}":
    get:
      tags:
      - Compensation Requests
      summary: Get compensation request details
      description: |
        Retrieves detailed information about a specific compensation change request
        including current and requested compensation, approval status, and change analysis.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Compensation request ID
        schema:
          type: integer
      responses:
        '200':
          description: Compensation request details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - Compensation Requests
      summary: Update compensation request
      description: 'Updates a pending compensation change request. Only pending requests
        can be edited.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Compensation request ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - compensation_request
              properties:
                compensation_request:
                  type: object
                  description: Input schema for creating/updating compensation requests
                  required:
                  - justification
                  - requested_compensation_type
                  properties:
                    justification:
                      type: string
                      minLength: 10
                      maxLength: 1000
                      example: Requesting salary increase based on performance review
                        and market analysis
                    effective_date:
                      type: string
                      format: date
                      example: '2024-03-01'
                    requested_compensation_type:
                      type: string
                      enum:
                      - salary
                      - hourly
                      - commission
                      - contract
                      example: salary
                    requested_annual_salary:
                      type: number
                      minimum: 0
                      nullable: true
                      example: 80000.0
                    requested_hourly_rate:
                      type: number
                      minimum: 0
                      nullable: true
                      example:
                    requested_pay_frequency:
                      type: string
                      enum:
                      - weekly
                      - biweekly
                      - monthly
                      - quarterly
                      - annually
                      example: monthly
                    requested_currency:
                      type: string
                      example: USD
      responses:
        '200':
          description: Compensation request updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/compensation/requests/{id}/cancel":
    patch:
      tags:
      - Compensation Requests
      summary: Cancel compensation request
      description: |
        Cancels a pending compensation change request. Only pending requests can be cancelled.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:compensation` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Compensation request ID
        schema:
          type: integer
      responses:
        '200':
          description: Compensation request cancelled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  compensation_request:
                    type: object
                    description: Detailed view of compensation request
                    properties:
                      id:
                        type: integer
                        example: 456
                      status:
                        type: string
                        enum:
                        - pending
                        - approved
                        - rejected
                        - cancelled
                        example: pending
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-20T14:30:00Z'
                      effective_date:
                        type: string
                        format: date
                        example: '2024-03-01'
                      justification:
                        type: string
                        example: Requesting salary increase based on performance review
                          and market analysis
                      current_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 75000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      requested_compensation:
                        type: object
                        properties:
                          type:
                            type: string
                            example: salary
                          annual_salary:
                            type: number
                            nullable: true
                            example: 80000.0
                          hourly_rate:
                            type: number
                            nullable: true
                            example:
                          pay_frequency:
                            type: string
                            example: monthly
                          currency:
                            type: string
                            example: USD
                      changes:
                        type: object
                        properties:
                          has_salary_change:
                            type: boolean
                            example: true
                          has_hourly_change:
                            type: boolean
                            example: false
                          has_type_change:
                            type: boolean
                            example: false
                          has_frequency_change:
                            type: boolean
                            example: false
                          has_currency_change:
                            type: boolean
                            example: false
                          summary:
                            type: string
                            example: 'Salary: $75,000 → $80,000'
                          impact:
                            type: object
                            properties:
                              type:
                                type: string
                                example: increase
                              amount:
                                type: number
                                example: 5000.0
                              percentage:
                                type: number
                                example: 6.7
                              description:
                                type: string
                                example: Increase of $5,000 (6.7%)
                      approval:
                        type: object
                        properties:
                          approver:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 789
                              name:
                                type: string
                                example: Jane Manager
                          approved_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          rejected_at:
                            type: string
                            format: date-time
                            nullable: true
                            example:
                          manager_notes:
                            type: string
                            nullable: true
                            example:
                      status_info:
                        type: object
                        properties:
                          days_pending:
                            type: integer
                            example: 5
                          is_urgent:
                            type: boolean
                            example: false
                          effective_soon:
                            type: boolean
                            example: true
                          can_edit:
                            type: boolean
                            example: true
                          can_cancel:
                            type: boolean
                            example: true
                  message:
                    type: string
                    example: Compensation request has been cancelled successfully
        '422':
          description: Request cannot be cancelled (not pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/epms/dashboard":
    get:
      tags:
      - EPMS Dashboard
      summary: Get EPMS dashboard
      description: |
        Retrieves combined dashboard data for the current user including goals, reviews, feedback, meetings, and action items.
        Provides an aggregated view of all EPMS activities and pending actions.

        **Required Scopes:** `read:epms_dashboard`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Dashboard data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    description: EPMS dashboard data with aggregated goals, reviews,
                      feedback, and meetings
                    properties:
                      goals:
                        type: object
                        properties:
                          active_count:
                            type: integer
                            example: 5
                          completed_count:
                            type: integer
                            example: 12
                          overdue_count:
                            type: integer
                            example: 2
                          recent_updates:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSGoal"
                      reviews:
                        type: object
                        properties:
                          pending_count:
                            type: integer
                            example: 1
                          in_progress_count:
                            type: integer
                            example: 2
                          completed_count:
                            type: integer
                            example: 8
                          recent_reviews:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSPerformanceReview"
                      feedback:
                        type: object
                        properties:
                          received_count:
                            type: integer
                            example: 15
                          pending_acknowledgment:
                            type: integer
                            example: 3
                          recent_feedback:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSContinuousFeedback"
                      meetings:
                        type: object
                        properties:
                          upcoming_count:
                            type: integer
                            example: 4
                          recent_count:
                            type: integer
                            example: 6
                          recent_meetings:
                            type: array
                            items:
                              "$ref": "#/components/schemas/EPMSMeeting"
                      action_items:
                        type: array
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - goal_update
                              - review_submit
                              - feedback_acknowledge
                              - meeting_schedule
                            title:
                              type: string
                            due_date:
                              type: string
                              format: date
                              nullable: true
                            priority:
                              type: string
                              enum:
                              - low
                              - medium
                              - high
                              - critical
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/dashboard/team":
    get:
      tags:
      - EPMS Dashboard
      summary: Get team dashboard
      description: |
        Retrieves team dashboard data for managers. Includes team goals, reviews, feedback, and pending approvals.
        Requires manager permissions.

        **Required Scopes:** `read:epms_dashboard`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Team dashboard data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  dashboard:
                    type: object
                    description: Team dashboard data for managers
                    properties:
                      team_stats:
                        type: object
                        properties:
                          team_size:
                            type: integer
                            example: 12
                          active_goals:
                            type: integer
                            example: 45
                          pending_reviews:
                            type: integer
                            example: 8
                          pending_approvals:
                            type: integer
                            example: 5
                      team_goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      pending_reviews:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSPerformanceReview"
                      team_feedback:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSContinuousFeedback"
                      upcoming_meetings:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSMeeting"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals":
    get:
      tags:
      - EPMS Goals
      summary: List goals
      description: |
        Retrieves a list of goals with filtering and pagination options. Supports filtering by scope, status, type, priority, and employee.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, team, all)
        schema:
          type: string
          enum:
          - mine
          - team
          - all
      - name: status
        in: query
        description: 'Filter by status (comma-separated: draft, in_review, active,
          on_hold, completed, cancelled, overdue)'
        schema:
          type: string
      - name: goal_type
        in: query
        description: Filter by goal type
        schema:
          type: string
          enum:
          - performance
          - development
          - behavior
          - project
          - skill
          - stretch
          - team
      - name: priority
        in: query
        description: Filter by priority
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: overdue
        in: query
        description: Filter overdue goals
        schema:
          type: boolean
      - name: search
        in: query
        description: Search by title or description
        schema:
          type: string
      - name: workflow_stage
        in: query
        description: 'Filter by workflow stage (comma-separated: draft, employee_review,
          manager_finalized, leadership_approved)'
        schema:
          type: string
      - name: start_date
        in: query
        description: Start of target_date range (inclusive)
        schema:
          type: string
          format: date
      - name: end_date
        in: query
        description: End of target_date range (inclusive)
        schema:
          type: string
          format: date
      - name: due_within_days
        in: query
        description: Filter goals due within N days
        schema:
          type: integer
      - name: performance_review_id
        in: query
        description: Filter by linked performance review ID
        schema:
          type: integer
      - name: sort_by
        in: query
        description: Sort field
        schema:
          type: string
          enum:
          - created_at
          - updated_at
          - target_date
          - start_date
          - progress_percentage
          - priority
          - status
          - title
      - name: sort_order
        in: query
        description: Sort direction
        schema:
          type: string
          enum:
          - asc
          - desc
      responses:
        '200':
          description: Goals retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Employee goal with progress tracking. Progress
                        updates are returned as a sibling array in GET /goals/{id},
                        not nested in the goal object.
                      properties:
                        id:
                          type: integer
                          example: 123
                        title:
                          type: string
                          example: Increase sales by 20%
                        description:
                          type: string
                          nullable: true
                          example: Achieve 20% growth in Q1 sales
                        goal_type:
                          type: string
                          enum:
                          - performance
                          - development
                          - behavior
                          - project
                          - skill
                          example: performance
                        goal_type_label:
                          type: string
                          nullable: true
                          description: Human-readable label for goal type
                        goal_category:
                          type: string
                          nullable: true
                          description: The focus area category key for this goal
                          example: professional_development
                        goal_category_label:
                          type: string
                          nullable: true
                          description: Human-readable label for the goal category
                          example: Professional Development
                        priority:
                          type: string
                          enum:
                          - low
                          - medium
                          - high
                          - critical
                          example: high
                        priority_label:
                          type: string
                          nullable: true
                          description: Human-readable label for priority
                        status:
                          type: string
                          enum:
                          - draft
                          - in_review
                          - active
                          - on_hold
                          - completed
                          - cancelled
                          - overdue
                          example: active
                        display_status:
                          type: string
                          nullable: true
                          description: Human-readable display status
                        start_date:
                          type: string
                          format: date
                          example: '2026-01-01'
                        target_date:
                          type: string
                          format: date
                          example: '2026-03-31'
                        completed_date:
                          type: string
                          format: date
                          nullable: true
                        progress_percentage:
                          type: number
                          minimum: 0
                          maximum: 100
                          example: 45.5
                        weight_percentage:
                          type: number
                          nullable: true
                          example: 30.0
                        workflow_stage:
                          type: string
                          enum:
                          - draft
                          - employee_review
                          - manager_finalized
                          - leadership_approved
                          description: Current workflow stage
                        workflow_stage_label:
                          type: string
                          nullable: true
                        is_smart_goal:
                          type: boolean
                          example: true
                        smart_score:
                          type: number
                          nullable: true
                        on_track:
                          type: boolean
                          nullable: true
                        days_until_due:
                          type: integer
                          nullable: true
                        is_overdue:
                          type: boolean
                          example: false
                        progress_update_allowed:
                          type: boolean
                          description: Whether progress updates can be submitted for
                            this goal
                        success_criteria:
                          type: string
                          nullable: true
                          description: Present when include_details is true (e.g.
                            show endpoint)
                          example: Reach $500K in sales
                        smart_criteria:
                          type: object
                          nullable: true
                          description: Present when include_details is true
                          properties:
                            is_specific:
                              type: boolean
                            is_measurable:
                              type: boolean
                            is_achievable:
                              type: boolean
                            is_relevant:
                              type: boolean
                            is_time_bound:
                              type: boolean
                        progress_updates_count:
                          type: integer
                          description: Present when include_details is true
                        latest_progress_update:
                          "$ref": "#/components/schemas/EPMSProgressUpdate"
                          nullable: true
                          description: Present when include_details is true
                        can_edit:
                          type: boolean
                          description: Present when include_details is true
                        can_complete:
                          type: boolean
                          description: Present when include_details is true
                        can_delete:
                          type: boolean
                          description: Present when include_details is true
                        requires_manager_approval:
                          type: boolean
                          description: Present when include_details is true
                        is_fully_approved:
                          type: boolean
                          description: Present when include_details is true
                        in_review_stage:
                          type: boolean
                          description: Present when include_details is true
                        can_cancel:
                          type: boolean
                          description: Present when include_details is true
                        can_put_on_hold:
                          type: boolean
                          description: Present when include_details is true
                        can_reactivate:
                          type: boolean
                          description: Present when include_details is true
                        can_send_to_employee:
                          type: boolean
                          description: Present when include_details is true
                        can_employee_review:
                          type: boolean
                          description: Present when include_details is true
                        can_manager_finalize:
                          type: boolean
                          description: Present when include_details is true
                        can_leadership_approve:
                          type: boolean
                          description: Present when include_details is true
                        manager_approved_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Present when include_details is true
                        leadership_approved_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: Present when include_details is true
                        employee:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                            email:
                              type: string
                              nullable: true
                              example: john@example.com
                            job_title:
                              type: string
                              nullable: true
                              description: 'The job title this business shows for
                                the person: their membership''s title (set by the
                                profile''s Job Title select), else the profile''s
                                own title.'
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '400':
          description: Invalid filter parameter (e.g. invalid workflow_stage or sort_by
            value)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                      details:
                        type: object
                        properties:
                          param:
                            type: string
                          invalid_values:
                            type: array
                            items:
                              type: string
                          valid_values:
                            type: array
                            items:
                              type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Goals
      summary: Create goal
      description: |
        Creates a new goal. Employee ID defaults to current user if not specified.
        Validates goal attributes including SMART criteria.

        `goal_category` is required when creating a goal and must be a valid category key for the business;
        use `GET /epms/goals/categories` to retrieve valid values.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal
              properties:
                goal:
                  type: object
                  description: Input schema for creating/updating goals
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Increase sales by 20%
                    description:
                      type: string
                      maxLength: 2000
                      example: Achieve 20% growth in Q1 sales
                    goal_type:
                      type: string
                      enum:
                      - performance
                      - development
                      - behavior
                      - project
                      - skill
                      example: performance
                    goal_category:
                      type: string
                      maxLength: 50
                      description: 'Focus area category key. Required on create for
                        non-department goals. Must be a valid category configured
                        for the business. Use `GET /epms/goals/categories` to retrieve
                        valid values. Default categories for IC: professional_development,
                        lead_self, work_with_others, contribute_to_business. Default
                        categories for leaders: professional_development, lead_self,
                        lead_others, lead_the_business. Business configuration may
                        differ.

                        '
                      example: professional_development
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: high
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_date:
                      type: string
                      format: date
                      example: '2026-03-31'
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 0
                    weight_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 30.0
                    success_criteria:
                      type: string
                      maxLength: 500
                      example: Reach $500K in sales
                    employee_id:
                      type: integer
                      example: 456
                    is_specific:
                      type: boolean
                      example: true
                    is_measurable:
                      type: boolean
                      example: true
                    is_achievable:
                      type: boolean
                      example: true
                    is_relevant:
                      type: boolean
                      example: true
                    is_time_bound:
                      type: boolean
                      example: true
      responses:
        '201':
          description: Goal created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}":
    get:
      tags:
      - EPMS Goals
      summary: Get goal details
      description: |
        Retrieves detailed goal information including progress updates and related data.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '200':
          description: Goal details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  progress_updates:
                    type: array
                    items:
                      type: object
                      description: Goal progress update record
                      properties:
                        id:
                          type: integer
                          example: 789
                        progress_percentage:
                          type: number
                          minimum: 0
                          maximum: 100
                          example: 50.0
                        update_notes:
                          type: string
                          nullable: true
                          example: Halfway through the quarter, on track
                        update_date:
                          type: string
                          format: date
                          example: '2026-01-15'
                        update_type:
                          type: string
                          enum:
                          - regular
                          - milestone
                          - completion
                          - revision
                          - comment
                          example: regular
                        challenges_faced:
                          type: string
                          nullable: true
                        support_needed:
                          type: string
                          nullable: true
                        attachments:
                          type: array
                          items:
                            type: string
                          nullable: true
                        updated_by:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:30:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Goals
      summary: Update goal
      description: |
        Updates an existing goal. Only certain fields can be updated based on goal status.

        `goal_category` can be included in the body to update the goal's focus area (same validation as create).

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal
              properties:
                goal:
                  type: object
                  description: Input schema for creating/updating goals
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Increase sales by 20%
                    description:
                      type: string
                      maxLength: 2000
                      example: Achieve 20% growth in Q1 sales
                    goal_type:
                      type: string
                      enum:
                      - performance
                      - development
                      - behavior
                      - project
                      - skill
                      example: performance
                    goal_category:
                      type: string
                      maxLength: 50
                      description: 'Focus area category key. Required on create for
                        non-department goals. Must be a valid category configured
                        for the business. Use `GET /epms/goals/categories` to retrieve
                        valid values. Default categories for IC: professional_development,
                        lead_self, work_with_others, contribute_to_business. Default
                        categories for leaders: professional_development, lead_self,
                        lead_others, lead_the_business. Business configuration may
                        differ.

                        '
                      example: professional_development
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: high
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_date:
                      type: string
                      format: date
                      example: '2026-03-31'
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 0
                    weight_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      example: 30.0
                    success_criteria:
                      type: string
                      maxLength: 500
                      example: Reach $500K in sales
                    employee_id:
                      type: integer
                      example: 456
                    is_specific:
                      type: boolean
                      example: true
                    is_measurable:
                      type: boolean
                      example: true
                    is_achievable:
                      type: boolean
                      example: true
                    is_relevant:
                      type: boolean
                      example: true
                    is_time_bound:
                      type: boolean
                      example: true
      responses:
        '200':
          description: Goal updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Goals
      summary: Delete goal
      description: |
        Deletes a goal if allowed based on status and permissions (can_delete?).

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '204':
          description: Goal deleted successfully
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/complete":
    post:
      tags:
      - EPMS Goals
      summary: Complete goal
      description: |
        Marks a goal as complete. Requires 100% progress or explicit completion.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '200':
          description: Goal completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal has been completed successfully
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/progress_updates":
    post:
      tags:
      - EPMS Goals
      summary: Add progress update
      description: |
        Adds a progress update to a goal. Can optionally update the goal's progress percentage.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - progress_update
              properties:
                progress_update:
                  type: object
                  description: Input schema for progress updates
                  properties:
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      description: Required for all types except 'comment'
                      example: 50.0
                    update_notes:
                      type: string
                      maxLength: 1000
                      example: Halfway through the quarter, on track
                    update_type:
                      type: string
                      enum:
                      - regular
                      - milestone
                      - completion
                      - revision
                      - comment
                      default: regular
                      example: regular
                    challenges_faced:
                      type: string
                      nullable: true
                      description: Obstacles or challenges encountered
                      example: Dependency on external vendor delayed delivery
                    support_needed:
                      type: string
                      nullable: true
                      description: Support or resources needed
                      example: Need additional budget approval for tooling
                    attachments:
                      type: array
                      items:
                        type: string
                      nullable: true
                      description: File references/attachment identifiers
      responses:
        '201':
          description: Progress update created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  progress_update:
                    type: object
                    description: Goal progress update record
                    properties:
                      id:
                        type: integer
                        example: 789
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 50.0
                      update_notes:
                        type: string
                        nullable: true
                        example: Halfway through the quarter, on track
                      update_date:
                        type: string
                        format: date
                        example: '2026-01-15'
                      update_type:
                        type: string
                        enum:
                        - regular
                        - milestone
                        - completion
                        - revision
                        - comment
                        example: regular
                      challenges_faced:
                        type: string
                        nullable: true
                      support_needed:
                        type: string
                        nullable: true
                      attachments:
                        type: array
                        items:
                          type: string
                        nullable: true
                      updated_by:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:30:00Z'
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/submit_employee_changes":
    post:
      tags:
      - EPMS Goals
      summary: Submit employee changes
      description: |
        Employee submits reviewed goal to manager (workflow: employee_review → manager_finalized).
        Only the goal owner can call this endpoint.
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  description: Optional comments when submitting
      responses:
        '200':
          description: Goal submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/submit_for_leadership_approval":
    post:
      tags:
      - EPMS Goals
      summary: Submit for leadership approval
      description: |
        Manager finalizes goal and optionally submits for leadership approval (requires can_finalize_goal?).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  description: Optional comments
      responses:
        '200':
          description: Goal finalized / submitted for leadership approval
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal approved successfully.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/leadership_approve":
    post:
      tags:
      - EPMS Goals
      summary: Leadership approve
      description: |
        Administrator performs leadership approval (manager_finalized → leadership_approved).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  description: Optional comments
      responses:
        '200':
          description: Goal approved by leadership
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal approved by leadership.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/leadership_reject":
    post:
      tags:
      - EPMS Goals
      summary: Leadership reject
      description: |
        Administrator rejects goal and sends back for revision (requires comments).
        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comments
              properties:
                comments:
                  type: string
                  description: Required rejection comments
      responses:
        '200':
          description: Goal rejected and sent back for revision
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal rejected and sent back for revision.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/manager_reject":
    post:
      tags:
      - EPMS Goals
      summary: Manager reject
      description: |
        Manager rejects goal and sends back to employee (comment or rejection_comment required).
        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  description: Comment when requesting changes (alternative to rejection_comment)
                rejection_comment:
                  type: string
                  description: Rejection comment (alternative to comment)
      responses:
        '200':
          description: Goal sent back to employee with feedback
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Goal sent back to employee with your feedback.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{goal_id}/documents/{id}":
    delete:
      tags:
      - EPMS Goals
      summary: Remove a document from a goal
      description: |
        Removes one document attached to a goal. The file itself is purged in the
        background after the response.

        Needs the Goals Management module (403 `module_not_enabled` when it is off)
        and a caller who may manage the goal's documents (403 `forbidden`, "You do
        not have permission to manage documents on this goal").

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope; `write:epms_goals` is not enough. A token
        without it is refused with 403 `insufficient_permissions` ("Required
        scope: destructive:epms").
      security:
      - BearerAuth: []
      parameters:
      - name: goal_id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      - name: id
        in: path
        required: true
        description: The document's attachment ID (as listed in the goal's attachments)
        schema:
          type: integer
      responses:
        '204':
          description: Document removed
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            `not_found` — "Goal not found" (no such goal in this business) or
            "Attachment not found" (no such document on this goal).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/epms/goals/{id}/cancel_change_request":
    post:
      tags:
      - EPMS Goals
      summary: Cancel change request
      description: |
        Manager cancels their rejection (employee_review → manager_finalized).
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      responses:
        '200':
          description: Change request cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  message:
                    type: string
                    example: Change request cancelled. Goal is ready for manager review.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/add_comment":
    post:
      tags:
      - EPMS Goals
      summary: Add comment
      description: |
        Adds a comment (GoalProgressUpdate with update_type 'comment') to a goal.
        Requires can_manage_employee_data? for the goal's employee.
        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Goal ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: string
                  description: Comment text (required)
      responses:
        '201':
          description: Comment created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    type: object
                    description: Goal progress update record
                    properties:
                      id:
                        type: integer
                        example: 789
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 50.0
                      update_notes:
                        type: string
                        nullable: true
                        example: Halfway through the quarter, on track
                      update_date:
                        type: string
                        format: date
                        example: '2026-01-15'
                      update_type:
                        type: string
                        enum:
                        - regular
                        - milestone
                        - completion
                        - revision
                        - comment
                        example: regular
                      challenges_faced:
                        type: string
                        nullable: true
                      support_needed:
                        type: string
                        nullable: true
                      attachments:
                        type: array
                        items:
                          type: string
                        nullable: true
                      updated_by:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:30:00Z'
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/stats":
    get:
      tags:
      - EPMS Goals
      summary: Get goal statistics
      description: |
        Retrieves goal statistics for dashboard display. Can be filtered by employee ID.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Optional filter by employee ID
        schema:
          type: integer
      responses:
        '200':
          description: Goal statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    type: object
                    description: Goal statistics for dashboard
                    properties:
                      total_goals:
                        type: integer
                        example: 20
                      active_goals:
                        type: integer
                        example: 12
                      completed_goals:
                        type: integer
                        example: 6
                      overdue_goals:
                        type: integer
                        example: 2
                      on_hold_goals:
                        type: integer
                        example: 0
                      cancelled_goals:
                        type: integer
                        example: 0
                      due_soon_count:
                        type: integer
                        description: Goals due within the next 7 days
                        example: 3
                      average_progress:
                        type: number
                        example: 45.5
                      completion_rate:
                        type: number
                        description: Percentage of completed goals (0-100)
                        example: 30.0
                      by_status:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts per status (draft, active, on_hold, completed,
                          cancelled, in_review, overdue)
                      by_priority:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts per priority (low, medium, high, critical)
                      by_type:
                        type: object
                        additionalProperties:
                          type: integer
                        description: Counts per goal type (performance, development,
                          behavior, project, skill)
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/categories":
    get:
      tags:
      - EPMS Goals
      summary: List goal categories
      description: |
        Returns valid goal categories filtered by employee role. Categories are role-based
        (leader vs individual contributor). Without `employee_id`, returns categories for
        the authenticated user's role. With `employee_id`, returns categories for that
        employee's role (requires read access to that employee).

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Get categories for this employee's role type. Defaults to the
          authenticated user if omitted.
        schema:
          type: integer
      responses:
        '200':
          description: Goal categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Response containing valid goal categories for a user's
                  role
                properties:
                  categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/types":
    get:
      tags:
      - EPMS Goals
      summary: List goal types
      description: |
        Returns valid goal types configured for the business, each with a value and label.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Goal types retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal_types:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                          example: performance
                        label:
                          type: string
                          example: Performance Goal
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/{id}/analyze_smart":
    post:
      tags:
      - EPMS Goals
      summary: AI SMART analysis for a saved goal
      description: |
        Runs AI-powered SMART criteria analysis on an existing goal. Scores each criterion
        (Specific, Measurable, Achievable, Relevant, Time-bound) from 1-10, provides feedback
        and suggestions, and returns an improved title and description. The analysis results
        are persisted on the goal record.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: SMART analysis completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  overall_score:
                    type: integer
                    minimum: 0
                    maximum: 100
                    example: 75
                  criteria:
                    type: object
                    properties:
                      specific:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      measurable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      achievable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      relevant:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      time_bound:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                  improved_title:
                    type: string
                    nullable: true
                  improved_description:
                    type: string
                    nullable: true
                  summary:
                    type: string
                    nullable: true
                  goal:
                    "$ref": "#/components/schemas/EPMSGoal"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/epms/goals/analyze_smart_text":
    post:
      tags:
      - EPMS Goals
      summary: AI SMART analysis for goal text (before saving)
      description: |
        Runs AI-powered SMART criteria analysis on goal text before the goal is saved.
        Useful for providing real-time feedback during goal creation. Scores each criterion
        from 1-10 and suggests improvements.

        **Required Scopes:** `write:epms_goals`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              - description
              properties:
                title:
                  type: string
                  description: The goal title to analyze
                description:
                  type: string
                  description: The goal description to analyze
                target_date:
                  type: string
                  format: date
                  description: Optional target date for time-bound analysis
                success_criteria:
                  type: string
                  description: Optional success criteria
      responses:
        '200':
          description: SMART text analysis completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  overall_score:
                    type: integer
                    minimum: 0
                    maximum: 100
                  criteria:
                    type: object
                    properties:
                      specific:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      measurable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      achievable:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      relevant:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                      time_bound:
                        "$ref": "#/components/schemas/EPMSSmartCriterionResult"
                  improved_title:
                    type: string
                    nullable: true
                  improved_description:
                    type: string
                    nullable: true
                  summary:
                    type: string
                    nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/epms/goal_templates":
    get:
      tags:
      - EPMS Goal Templates
      summary: List goal templates
      description: |
        Returns active goal templates for the business, with optional filtering by goal type.
        Templates provide pre-configured goal structures that can be used to create new goals.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: goal_type
        in: query
        description: Filter templates by goal type
        schema:
          type: string
      responses:
        '200':
          description: Goal templates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalTemplate"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goal_templates/{id}":
    get:
      tags:
      - EPMS Goal Templates
      summary: Get goal template details
      description: |
        Returns detailed information about a specific goal template, including success criteria,
        SMART criteria, measurement method, and usage statistics.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Goal template details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal_template:
                    allOf:
                    - "$ref": "#/components/schemas/EPMSGoalTemplate"
                    - type: object
                      properties:
                        success_criteria:
                          type: string
                          nullable: true
                        resources_needed:
                          type: string
                          nullable: true
                        measurement_method:
                          type: string
                          nullable: true
                        smart_criteria:
                          type: object
                          properties:
                            is_specific:
                              type: boolean
                            is_measurable:
                              type: boolean
                            is_achievable:
                              type: boolean
                            is_relevant:
                              type: boolean
                            is_time_bound:
                              type: boolean
                        missing_smart_criteria:
                          type: array
                          items:
                            type: string
                        usage_statistics:
                          type: object
                          properties:
                            total_goals:
                              type: integer
                            active_goals:
                              type: integer
                            completed_goals:
                              type: integer
                            completion_rate:
                              type: number
                            last_used:
                              type: string
                              format: date-time
                              nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/goals/creation_config":
    get:
      tags:
      - EPMS Goals
      summary: Goal creation configuration
      description: |
        Returns all dynamic properties needed to render the goal create form: goal templates,
        goal types, priorities, goal categories, SMART goal checklist configuration, and
        whether approval is required. Without `employee_id`, returns categories for
        the authenticated user's role. With `employee_id`, returns role-specific categories
        for that employee.

        **Required Scopes:** `read:epms_goals`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Get role-specific categories for this employee. Defaults to the
          authenticated user if omitted.
        schema:
          type: integer
      responses:
        '200':
          description: Goal creation configuration retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: All dynamic properties needed to render the goal create
                  form
                properties:
                  goal_templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalTemplateOption"
                  goal_types:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
                  priorities:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
                  goal_categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/EPMSGoalCategoryOption"
                  goal_checklist:
                    "$ref": "#/components/schemas/EPMSGoalChecklist"
                  requires_approval:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews":
    get:
      tags:
      - EPMS Performance Reviews
      summary: List performance reviews
      description: |
        Retrieves a list of performance reviews with filtering options. Supports filtering by scope, status, type, and employee.

        **Required Scopes:** `read:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, my_team, all)
        schema:
          type: string
          enum:
          - mine
          - my_team
          - all
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - created
          - in_progress
          - completed
          - approved
          - archived
      - name: review_type
        in: query
        description: Filter by review type
        schema:
          type: string
          enum:
          - annual
          - biannual
          - quarterly
          - probationary
          - ad_hoc
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: overdue
        in: query
        description: Filter overdue reviews
        schema:
          type: boolean
      responses:
        '200':
          description: Performance reviews retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: 'Performance review with assessment details, as
                        Api::V1::Epms::BaseController #format_performance_review_response
                        sends it. The fields under "detail only" are present on the
                        single-review responses (show, create, update, submit, approve),
                        not on the list.'
                      properties:
                        id:
                          type: integer
                          example: 234
                        title:
                          type: string
                          example: Q1 2026 Performance Review
                        status:
                          type: string
                          enum:
                          - created
                          - in_progress
                          - completed
                          - approved
                          - archived
                          example: in_progress
                        display_status:
                          type: string
                          description: The status as shown — `overdue` for a pending
                            review past its due date, else `status`.
                          enum:
                          - created
                          - in_progress
                          - completed
                          - approved
                          - archived
                          - overdue
                          example: in_progress
                        status_color:
                          type: string
                          description: Bootstrap badge color variant for the display
                            status
                          enum:
                          - info
                          - primary
                          - warning
                          - success
                          - danger
                          - secondary
                          example: primary
                        display_status_label:
                          type: string
                          description: The display status in words, as the web list
                            labels it.
                          example: In Progress
                        review_type:
                          type: string
                          enum:
                          - annual
                          - biannual
                          - quarterly
                          - probationary
                          - ad_hoc
                          example: quarterly
                        review_period:
                          type: string
                          description: The review period in words.
                          example: Jan 2026 - Mar 2026
                        review_period_start:
                          type: string
                          format: date
                          example: '2026-01-01'
                        review_period_end:
                          type: string
                          format: date
                          example: '2026-03-31'
                        due_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-04-15'
                        overall_rating:
                          type: number
                          nullable: true
                          description: The numeric rating — null unless the caller
                            may see the manager's work and the business shows numeric
                            scores.
                          example: 4.5
                        overall_rating_display:
                          type: string
                          nullable: true
                          description: The rating as the business displays it (a number,
                            a label, or hidden); null when the caller may not see
                            the manager's work.
                          example: 4.5 / 5
                        rating_scale:
                          type: integer
                          example: 5
                        completion_percentage:
                          type: number
                          example: 60
                        is_overdue:
                          type: boolean
                          example: false
                        days_until_due:
                          type: integer
                          nullable: true
                          example: 12
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                            email:
                              type: string
                              nullable: true
                              example: john.doe@example.com
                            job_title:
                              type: string
                              nullable: true
                              description: 'The job title this business shows for
                                the person: their membership''s title (set by the
                                profile''s Job Title select), else the profile''s
                                own title.'
                              example: Software Engineer
                        reviewer:
                          type: object
                          description: The reviewer; absent when the review has none.
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                            email:
                              type: string
                              nullable: true
                              example: jane.manager@example.com
                        manager_comments:
                          type: string
                          nullable: true
                          description: Detail only. Null when the caller may not see
                            the manager's work.
                          example: Great progress this quarter
                        employee_self_assessment:
                          type: string
                          nullable: true
                          description: Detail only.
                        goals_count:
                          type: integer
                          description: Detail only.
                        completed_goals_count:
                          type: integer
                          description: Detail only.
                        goals_completion_percentage:
                          type: number
                          description: Detail only.
                        can_edit:
                          type: boolean
                          description: Detail only.
                        can_submit:
                          type: boolean
                          description: Detail only.
                        can_approve:
                          type: boolean
                          description: Detail only.
                        self_assessment_required:
                          type: boolean
                          description: Detail only.
                        employee_submitted_self_assessment:
                          type: boolean
                          description: Detail only.
                        meeting_completed:
                          type: boolean
                          description: Detail only.
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Performance Reviews
      summary: Create performance review
      description: |
        Creates a new performance review. Managers only. Employee ID is required.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - performance_review
              properties:
                performance_review:
                  type: object
                  description: Input schema for creating/updating performance reviews
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 2026 Performance Review
                    employee_id:
                      type: integer
                      example: 456
                    review_type:
                      type: string
                      enum:
                      - annual
                      - quarterly
                      - probation
                      - project
                      - performance_improvement
                      example: quarterly
                    review_period_start:
                      type: string
                      format: date
                      example: '2026-01-01'
                    review_period_end:
                      type: string
                      format: date
                      example: '2026-03-31'
                    due_date:
                      type: string
                      format: date
                      example: '2026-04-15'
                    performance_review_template_id:
                      type: integer
                      nullable: true
                    manager_comments:
                      type: string
                      maxLength: 5000
                      example: Great progress this quarter
                    overall_rating:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.5
      responses:
        '201':
          description: Performance review created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}":
    get:
      tags:
      - EPMS Performance Reviews
      summary: Get performance review details
      description: |
        Retrieves detailed performance review information. Can include linked goals and ratings.

        **Required Scopes:** `read:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      - name: include_goals
        in: query
        description: Include linked goals
        schema:
          type: boolean
      - name: include_ratings
        in: query
        description: Include ratings (if user has access)
        schema:
          type: boolean
      responses:
        '200':
          description: Performance review details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Performance Reviews
      summary: Update performance review
      description: |
        Updates a performance review. Only certain fields can be updated based on review status.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - performance_review
              properties:
                performance_review:
                  type: object
                  description: Input schema for creating/updating performance reviews
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 2026 Performance Review
                    employee_id:
                      type: integer
                      example: 456
                    review_type:
                      type: string
                      enum:
                      - annual
                      - quarterly
                      - probation
                      - project
                      - performance_improvement
                      example: quarterly
                    review_period_start:
                      type: string
                      format: date
                      example: '2026-01-01'
                    review_period_end:
                      type: string
                      format: date
                      example: '2026-03-31'
                    due_date:
                      type: string
                      format: date
                      example: '2026-04-15'
                    performance_review_template_id:
                      type: integer
                      nullable: true
                    manager_comments:
                      type: string
                      maxLength: 5000
                      example: Great progress this quarter
                    overall_rating:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.5
      responses:
        '200':
          description: Performance review updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Performance Reviews
      summary: Delete performance review
      description: |
        Deletes a performance review if allowed based on status and permissions.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '204':
          description: Performance review deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/submit":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Submit performance review
      description: |
        Submits a performance review for approval. Review must be in progress status.

        When the business has manager approval switched off, the submission IS
        the finish (as on desktop and phone): the review is also approved and
        finalized in the same call, so `performance_review` comes back approved.
        If that finalization fails the call answers 422 `approve_failed`
        ("The review was submitted but could not be finalized: …").

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '200':
          description: Performance review submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
                  message:
                    type: string
                    example: Performance review has been submitted for approval
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/approve":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Approve performance review
      description: |
        Approves a submitted performance review. Requires manager or admin permissions.

        Runs the same finalization as desktop, phone and bulk approve (merit
        recommendation, outcome automation, post-review survey, completion mail,
        audit). Refused 403 `forbidden` for the employee on the review AND for
        the reviewer who submitted it for approval — someone else approves it
        (their manager, an admin or an EPMS app admin). A failed finalization is
        422 `approve_failed` with its reason.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '200':
          description: Performance review approved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
                  message:
                    type: string
                    example: Performance review has been approved
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/archive":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Archive performance review
      description: |
        Archives a completed or approved performance review — the recoverable
        alternative to deletion. Requires the review's reviewer or HR/admin
        permissions.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      responses:
        '200':
          description: Performance review archived successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/self_assessment":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Submit self assessment
      description: |
        Submits employee self-assessment for a performance review. Includes overall summary, strengths, areas for improvement, and ratings.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - self_assessment
              properties:
                self_assessment:
                  type: object
                  description: Input schema for self-assessment submission
                  properties:
                    overall_summary:
                      type: string
                      maxLength: 2000
                      example: I achieved all my goals this quarter
                    strengths:
                      type: string
                      maxLength: 1000
                      example: Strong communication and leadership
                    areas_for_improvement:
                      type: string
                      maxLength: 1000
                      example: Time management
                    accomplishments:
                      type: string
                      maxLength: 2000
                      example: Completed 3 major projects
                    goals_reflection:
                      type: string
                      maxLength: 1000
                      example: Met 90% of goals
                    training_needs:
                      type: string
                      maxLength: 500
                      example: Advanced project management
                    career_aspirations:
                      type: string
                      maxLength: 1000
                      example: Lead a larger team
                    self_rating:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.0
                    ratings:
                      type: array
                      items:
                        type: object
                        properties:
                          category:
                            type: string
                            example: Communication
                          rating:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 4.5
                          comments:
                            type: string
                            example: Excellent
                    goals:
                      type: array
                      items:
                        type: object
                        properties:
                          goal_id:
                            type: integer
                            example: 123
                          achievement_notes:
                            type: string
                            example: Exceeded expectations
                          self_rating:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 5.0
      responses:
        '200':
          description: Self assessment submitted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
                  message:
                    type: string
                    example: Self assessment has been submitted
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/{id}/link_goals":
    post:
      tags:
      - EPMS Performance Reviews
      summary: Link goals to review
      description: |
        Links goals to a performance review for evaluation.

        **Required Scopes:** `write:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Performance review ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal_ids
              properties:
                goal_ids:
                  type: array
                  items:
                    type: integer
                  description: Array of goal IDs to link
      responses:
        '200':
          description: Goals linked successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  performance_review:
                    type: object
                    description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
                      #format_performance_review_response sends it. The fields under
                      "detail only" are present on the single-review responses (show,
                      create, update, submit, approve), not on the list.'
                    properties:
                      id:
                        type: integer
                        example: 234
                      title:
                        type: string
                        example: Q1 2026 Performance Review
                      status:
                        type: string
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        example: in_progress
                      display_status:
                        type: string
                        description: The status as shown — `overdue` for a pending
                          review past its due date, else `status`.
                        enum:
                        - created
                        - in_progress
                        - completed
                        - approved
                        - archived
                        - overdue
                        example: in_progress
                      status_color:
                        type: string
                        description: Bootstrap badge color variant for the display
                          status
                        enum:
                        - info
                        - primary
                        - warning
                        - success
                        - danger
                        - secondary
                        example: primary
                      display_status_label:
                        type: string
                        description: The display status in words, as the web list
                          labels it.
                        example: In Progress
                      review_type:
                        type: string
                        enum:
                        - annual
                        - biannual
                        - quarterly
                        - probationary
                        - ad_hoc
                        example: quarterly
                      review_period:
                        type: string
                        description: The review period in words.
                        example: Jan 2026 - Mar 2026
                      review_period_start:
                        type: string
                        format: date
                        example: '2026-01-01'
                      review_period_end:
                        type: string
                        format: date
                        example: '2026-03-31'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-15'
                      overall_rating:
                        type: number
                        nullable: true
                        description: The numeric rating — null unless the caller may
                          see the manager's work and the business shows numeric scores.
                        example: 4.5
                      overall_rating_display:
                        type: string
                        nullable: true
                        description: The rating as the business displays it (a number,
                          a label, or hidden); null when the caller may not see the
                          manager's work.
                        example: 4.5 / 5
                      rating_scale:
                        type: integer
                        example: 5
                      completion_percentage:
                        type: number
                        example: 60
                      is_overdue:
                        type: boolean
                        example: false
                      days_until_due:
                        type: integer
                        nullable: true
                        example: 12
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john.doe@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Software Engineer
                      reviewer:
                        type: object
                        description: The reviewer; absent when the review has none.
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            nullable: true
                            example: jane.manager@example.com
                      manager_comments:
                        type: string
                        nullable: true
                        description: Detail only. Null when the caller may not see
                          the manager's work.
                        example: Great progress this quarter
                      employee_self_assessment:
                        type: string
                        nullable: true
                        description: Detail only.
                      goals_count:
                        type: integer
                        description: Detail only.
                      completed_goals_count:
                        type: integer
                        description: Detail only.
                      goals_completion_percentage:
                        type: number
                        description: Detail only.
                      can_edit:
                        type: boolean
                        description: Detail only.
                      can_submit:
                        type: boolean
                        description: Detail only.
                      can_approve:
                        type: boolean
                        description: Detail only.
                      self_assessment_required:
                        type: boolean
                        description: Detail only.
                      employee_submitted_self_assessment:
                        type: boolean
                        description: Detail only.
                      meeting_completed:
                        type: boolean
                        description: Detail only.
                  message:
                    type: string
                    example: Goals have been linked to the performance review
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/performance_reviews/stats":
    get:
      tags:
      - EPMS Performance Reviews
      summary: Get performance review statistics
      description: |
        Retrieves performance review statistics. Can be filtered by employee ID.
        Also returns `my_review_summary` with the current user's personal review
        summary matching the "My Review Status" widget on the web dashboard for
        single-employee contexts (`scope=mine` or default scope without aggregate views).

        **Required Scopes:** `read:epms_reviews`
      security:
      - BearerAuth: []
      parameters:
      - name: employee_id
        in: query
        description: Optional filter by employee ID
        schema:
          type: integer
      - name: scope
        in: query
        description: Optional scope filter. Aggregate scopes omit `my_review_summary`.
        schema:
          type: string
          enum:
          - mine
          - my_team
          - all
      responses:
        '200':
          description: Performance review statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    type: object
                    description: Performance review statistics
                    properties:
                      total_reviews:
                        type: integer
                        example: 15
                      pending_reviews:
                        type: integer
                        example: 5
                      completed_reviews:
                        type: integer
                        example: 8
                      overdue_reviews:
                        type: integer
                        example: 2
                      due_soon_count:
                        type: integer
                        example: 3
                      completion_rate:
                        type: number
                        example: 53.3
                      by_status:
                        type: object
                        description: Count of reviews by status key
                        additionalProperties:
                          type: integer
                        example:
                          created: 2
                          in_progress: 5
                          completed: 3
                          approved: 2
                          archived: 1
                      by_type:
                        type: object
                        description: Count of reviews by review_type key
                        additionalProperties:
                          type: integer
                        example:
                          annual: 8
                          quarterly: 5
                          ad_hoc: 2
                      average_rating:
                        type: number
                        nullable: true
                        example: 4.2
                  my_review_summary:
                    type: object
                    description: Personal review summary for the current user (matches
                      "My Review Status" widget)
                    properties:
                      total_reviews:
                        type: integer
                        description: Total number of reviews where the user is the
                          employee
                        example: 5
                      completed_reviews:
                        type: integer
                        description: Reviews with status completed or approved
                        example: 3
                      pending_reviews:
                        type: integer
                        description: Reviews with status created or in_progress
                        example: 1
                      average_rating:
                        type: number
                        description: Average overall_rating across completed/approved
                          reviews (rounded to 1 decimal, defaults to 0)
                        example: 4.2
                      has_active_review:
                        type: boolean
                        description: Whether the user has an active (created or in_progress)
                          review
                        example: true
                      current_review:
                        type: object
                        nullable: true
                        description: The most recent active review summary
                        properties:
                          id:
                            type: integer
                          title:
                            type: string
                          status:
                            type: string
                          display_status:
                            type: string
                          status_color:
                            type: string
                            enum:
                            - info
                            - primary
                            - success
                            - danger
                            - secondary
                          start_date:
                            type: string
                            format: date
                            nullable: true
                          due_date:
                            type: string
                            format: date
                            nullable: true
                          days_until_due:
                            type: integer
                            nullable: true
                          completion_percentage:
                            type: integer
                          requires_self_assessment:
                            type: boolean
                          self_assessment_completed:
                            type: boolean
                      pending_actions:
                        type: array
                        description: Status-based action items for the user
                        items:
                          type: object
                          properties:
                            type:
                              type: string
                              enum:
                              - begin_review
                              - complete_self_assessment
                              - awaiting_approval
                            text:
                              type: string
                            priority:
                              type: string
                              enum:
                              - high
                              - medium
                              - low
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: List feedback
      description: |
        Retrieves a list of feedback with filtering options. Supports filtering by scope, status, type, visibility, and receiver.

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (given, received, team, all)
        schema:
          type: string
          enum:
          - given
          - received
          - team
          - all
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - draft
          - sent
          - acknowledged
          - archived
      - name: feedback_type
        in: query
        description: Filter by feedback type
        schema:
          type: string
          enum:
          - praise
          - constructive
          - goal_progress
          - general
      - name: visibility
        in: query
        description: Filter by visibility
        schema:
          type: string
          enum:
          - private
          - manager
          - hr
          - public
      - name: receiver_id
        in: query
        description: Filter by receiver ID
        schema:
          type: integer
      - name: pending_acknowledgment
        in: query
        description: Filter pending acknowledgment
        schema:
          type: boolean
      responses:
        '200':
          description: Feedback retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Continuous feedback record
                      properties:
                        id:
                          type: integer
                          example: 345
                        subject:
                          type: string
                          example: Great work on the project
                        content:
                          type: string
                          example: Your presentation was excellent and well-received
                            by the team.
                        feedback_type:
                          type: string
                          enum:
                          - praise
                          - constructive
                          - goal_progress
                          - general
                          example: praise
                        status:
                          type: string
                          enum:
                          - draft
                          - sent
                          - acknowledged
                          - archived
                          example: sent
                        visibility:
                          type: string
                          enum:
                          - private
                          - public
                          - manager_only
                          example: private
                        is_anonymous:
                          type: boolean
                          example: false
                        giver:
                          type: object
                          nullable: true
                          description: 'Feedback giver info. Returns only `{ name:
                            "Anonymous" }` when feedback is anonymous.'
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                            email:
                              type: string
                              format: email
                              example: jane.manager@company.com
                            job_title:
                              type: string
                              nullable: true
                              description: 'The job title this business shows for
                                the person: their membership''s title (set by the
                                profile''s Job Title select), else the profile''s
                                own title.'
                              example: Engineering Manager
                            profile_photo_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Full-size profile photo URL (200x200)
                              example: https://example.com/photos/jane-200x200.jpg
                            profile_photo_thumbnail_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Thumbnail profile photo URL (40x40)
                              example: https://example.com/photos/jane-40x40.jpg
                        receiver:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                            email:
                              type: string
                              format: email
                              example: john.doe@company.com
                            job_title:
                              type: string
                              nullable: true
                              description: 'The job title this business shows for
                                the person: their membership''s title (set by the
                                profile''s Job Title select), else the profile''s
                                own title.'
                              example: Senior Product Manager
                            profile_photo_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Full-size profile photo URL (200x200)
                              example: https://example.com/photos/john-200x200.jpg
                            profile_photo_thumbnail_url:
                              type: string
                              format: uri
                              nullable: true
                              description: Thumbnail profile photo URL (40x40)
                              example: https://example.com/photos/john-40x40.jpg
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-01-20T10:00:00Z'
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                        sent_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-01-15T15:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Create feedback (draft)
      description: |
        Creates new feedback. Starts as draft status. Must be sent separately.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feedback
              properties:
                feedback:
                  type: object
                  description: Input schema for creating/updating feedback
                  required:
                  - receiver_id
                  - subject
                  - content
                  properties:
                    receiver_id:
                      type: integer
                      example: 456
                    subject:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Great work on the project
                    content:
                      type: string
                      minLength: 1
                      maxLength: 2000
                      example: Your presentation was excellent and well-received by
                        the team.
                    feedback_type:
                      type: string
                      enum:
                      - praise
                      - constructive
                      - goal_progress
                      - general
                      default: praise
                      example: praise
                    visibility:
                      type: string
                      enum:
                      - private
                      - public
                      - manager_only
                      default: private
                      example: private
                    is_anonymous:
                      type: boolean
                      default: false
                      example: false
      responses:
        '201':
          description: Feedback created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: Get feedback details
      description: |
        Retrieves detailed feedback information.

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Continuous Feedback
      summary: Update feedback
      description: |
        Updates feedback. Only drafts can be edited by the giver.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feedback
              properties:
                feedback:
                  type: object
                  description: Input schema for creating/updating feedback
                  required:
                  - receiver_id
                  - subject
                  - content
                  properties:
                    receiver_id:
                      type: integer
                      example: 456
                    subject:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Great work on the project
                    content:
                      type: string
                      minLength: 1
                      maxLength: 2000
                      example: Your presentation was excellent and well-received by
                        the team.
                    feedback_type:
                      type: string
                      enum:
                      - praise
                      - constructive
                      - goal_progress
                      - general
                      default: praise
                      example: praise
                    visibility:
                      type: string
                      enum:
                      - private
                      - public
                      - manager_only
                      default: private
                      example: private
                    is_anonymous:
                      type: boolean
                      default: false
                      example: false
      responses:
        '200':
          description: Feedback updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Continuous Feedback
      summary: Delete feedback
      description: |
        Deletes feedback. Only drafts can be deleted by the giver.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '204':
          description: Feedback deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}/send":
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Send feedback
      description: |
        Sends the feedback to the receiver. Changes status from draft to sent.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
                  message:
                    type: string
                    example: Feedback has been sent
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}/acknowledge":
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Acknowledge feedback
      description: |
        Acknowledges received feedback. Changes status from sent to acknowledged.

        **Required Scopes:** `write:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback acknowledged successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
                  message:
                    type: string
                    example: Feedback has been acknowledged
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/{id}/archive":
    post:
      tags:
      - EPMS Continuous Feedback
      summary: Archive feedback
      description: |
        Archives feedback. Changes status to archived.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Feedback ID
        schema:
          type: integer
      responses:
        '200':
          description: Feedback archived successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    type: object
                    description: Continuous feedback record
                    properties:
                      id:
                        type: integer
                        example: 345
                      subject:
                        type: string
                        example: Great work on the project
                      content:
                        type: string
                        example: Your presentation was excellent and well-received
                          by the team.
                      feedback_type:
                        type: string
                        enum:
                        - praise
                        - constructive
                        - goal_progress
                        - general
                        example: praise
                      status:
                        type: string
                        enum:
                        - draft
                        - sent
                        - acknowledged
                        - archived
                        example: sent
                      visibility:
                        type: string
                        enum:
                        - private
                        - public
                        - manager_only
                        example: private
                      is_anonymous:
                        type: boolean
                        example: false
                      giver:
                        type: object
                        nullable: true
                        description: 'Feedback giver info. Returns only `{ name: "Anonymous"
                          }` when feedback is anonymous.'
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                          email:
                            type: string
                            format: email
                            example: jane.manager@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Engineering Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/jane-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/jane-40x40.jpg
                      receiver:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            format: email
                            example: john.doe@company.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                            example: Senior Product Manager
                          profile_photo_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Full-size profile photo URL (200x200)
                            example: https://example.com/photos/john-200x200.jpg
                          profile_photo_thumbnail_url:
                            type: string
                            format: uri
                            nullable: true
                            description: Thumbnail profile photo URL (40x40)
                            example: https://example.com/photos/john-40x40.jpg
                      acknowledged_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-20T10:00:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                      sent_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-01-15T15:00:00Z'
                  message:
                    type: string
                    example: Feedback has been archived
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/stats":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: Get feedback statistics
      description: |
        Retrieves feedback statistics. Can be filtered by scope.

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        description: Filter by scope (given, received)
        schema:
          type: string
          enum:
          - given
          - received
      responses:
        '200':
          description: Feedback statistics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    type: object
                    description: Feedback statistics
                    properties:
                      given:
                        type: object
                        properties:
                          total:
                            type: integer
                            example: 25
                          by_type:
                            type: object
                            properties:
                              praise:
                                type: integer
                                example: 15
                              constructive:
                                type: integer
                                example: 5
                              recognition:
                                type: integer
                                example: 3
                              coaching:
                                type: integer
                                example: 2
                      received:
                        type: object
                        properties:
                          total:
                            type: integer
                            example: 18
                          pending_acknowledgment:
                            type: integer
                            example: 3
                          acknowledged:
                            type: integer
                            example: 15
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/continuous_feedbacks/user_picker":
    get:
      tags:
      - EPMS Continuous Feedback
      summary: User picker for feedback giving / receiving
      description: |
        Returns a paginated, searchable list of eligible users for the mobile
        feedback user-picker, scoped by the requested mode.

        | Mode | Returns |
        |------|---------|
        | `giving` | All active business members except the current user — anyone can receive feedback |
        | `receiving` | Users whose received-feedback the current user may view: **HR/Admin** → all users; **Managers** → themselves + direct reports; **Employees** → all active users (for filtering their own inbox) |

        **Required Scopes:** `read:epms_feedback`
      security:
      - BearerAuth: []
      parameters:
      - name: mode
        in: query
        required: true
        description: |
          Picker mode:
          - `giving` — users the current user can give feedback **to**
          - `receiving` — users whose received feedback the current user can view
        schema:
          type: string
          enum:
          - giving
          - receiving
        example: giving
      - name: search
        in: query
        description: Search by full name, or email address where the People directory
          shows the caller email (case-insensitive)
        schema:
          type: string
          example: Jane
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Eligible users retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: |
                        Lightweight user object returned by the feedback user-picker endpoint.
                        Used for selecting recipients (giving mode) or viewing feedback targets (receiving mode).
                      properties:
                        id:
                          type: integer
                          description: User's internal ID
                          example: 456
                        name:
                          type: string
                          description: User's full name
                          example: Jane Smith
                        email:
                          type: string
                          format: email
                          nullable: true
                          description: User's email address
                          example: jane.smith@company.com
                        job_title:
                          type: string
                          nullable: true
                          description: 'The job title this business shows for the
                            person: their membership''s title (set by the profile''s
                            Job Title select), else the profile''s own title.'
                          example: Senior Engineer
                        department:
                          type: string
                          nullable: true
                          description: User's department name
                          example: Engineering
                        avatar_url:
                          type: string
                          format: uri
                          nullable: true
                          description: Full-size profile photo URL (200×200 px)
                          example: https://example.com/photos/jane-200x200.jpg
                        avatar_thumbnail_url:
                          type: string
                          format: uri
                          nullable: true
                          description: Thumbnail profile photo URL (40×40 px)
                          example: https://example.com/photos/jane-40x40.jpg
                      required:
                      - id
                      - name
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
              example:
                items:
                - id: 456
                  name: Jane Smith
                  email: jane.smith@company.com
                  job_title: Senior Engineer
                  department: Engineering
                  avatar_url: https://example.com/photos/jane-200x200.jpg
                  avatar_thumbnail_url: https://example.com/photos/jane-40x40.jpg
                - id: 789
                  name: John Doe
                  email: john.doe@company.com
                  job_title: Product Manager
                  department: Product
                  avatar_url:
                  avatar_thumbnail_url:
                meta:
                  total_count: 42
                  total_pages: 2
                  current_page: 1
                  per_page: 25
                  has_next_page: true
                  has_prev_page: false
        '400':
          description: Invalid mode parameter
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: invalid_mode
                      message:
                        type: string
                        example: 'Invalid mode ''foo''. Valid values are: giving,
                          receiving'
                      details:
                        type: object
                        properties:
                          valid_modes:
                            type: array
                            items:
                              type: string
                            example:
                            - giving
                            - receiving
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/development_plans":
    get:
      tags:
      - EPMS Development Plans
      summary: List development plans
      description: |
        Retrieves a list of development plans with filtering options.

        **Required Scopes:** `read:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, team)
        schema:
          type: string
          enum:
          - mine
          - team
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - draft
          - active
          - on_hold
          - completed
          - cancelled
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      responses:
        '200':
          description: Development plans retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Employee development plan
                      properties:
                        id:
                          type: integer
                          example: 456
                        title:
                          type: string
                          example: Leadership Development Plan
                        description:
                          type: string
                          nullable: true
                          example: Focus on developing leadership skills
                        status:
                          type: string
                          enum:
                          - draft
                          - active
                          - completed
                          - cancelled
                          example: active
                        start_date:
                          type: string
                          format: date
                          example: '2026-01-01'
                        target_completion_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-12-31'
                        plan_type:
                          type: string
                          nullable: true
                          example: leadership
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        manager:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                        goals:
                          type: array
                          items:
                            "$ref": "#/components/schemas/EPMSGoal"
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Development Plans
      summary: Create development plan
      description: |
        Creates a new development plan.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - development_plan
              properties:
                development_plan:
                  type: object
                  description: Input schema for creating/updating development plans
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Leadership Development Plan
                    description:
                      type: string
                      maxLength: 2000
                      example: Focus on developing leadership skills
                    status:
                      type: string
                      enum:
                      - draft
                      - active
                      - completed
                      - cancelled
                      example: active
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_completion_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-12-31'
                    employee_id:
                      type: integer
                      example: 456
                    plan_type:
                      type: string
                      example: leadership
      responses:
        '201':
          description: Development plan created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/development_plans/{id}":
    get:
      tags:
      - EPMS Development Plans
      summary: Get development plan details
      description: |
        Retrieves detailed development plan information. Can include development goals.

        **Required Scopes:** `read:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      - name: include_goals
        in: query
        description: Include development goals
        schema:
          type: boolean
      responses:
        '200':
          description: Development plan details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Development Plans
      summary: Update development plan
      description: |
        Updates a development plan.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - development_plan
              properties:
                development_plan:
                  type: object
                  description: Input schema for creating/updating development plans
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Leadership Development Plan
                    description:
                      type: string
                      maxLength: 2000
                      example: Focus on developing leadership skills
                    status:
                      type: string
                      enum:
                      - draft
                      - active
                      - completed
                      - cancelled
                      example: active
                    start_date:
                      type: string
                      format: date
                      example: '2026-01-01'
                    target_completion_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-12-31'
                    employee_id:
                      type: integer
                      example: 456
                    plan_type:
                      type: string
                      example: leadership
      responses:
        '200':
          description: Development plan updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Development Plans
      summary: Delete development plan
      description: |
        Deletes a development plan if allowed based on status and permissions.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      responses:
        '204':
          description: Development plan deleted successfully
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/development_plans/{id}/goals":
    post:
      tags:
      - EPMS Development Plans
      summary: Add goal to development plan
      description: |
        Adds a development goal to the plan.

        **Required Scopes:** `write:epms_development`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Development plan ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - goal
              properties:
                goal:
                  type: object
                  description: Input schema for development plan goals
                  required:
                  - title
                  - target_date
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Complete Leadership Training
                    description:
                      type: string
                      maxLength: 2000
                      example: Finish the leadership certification program
                    goal_type:
                      type: string
                      enum:
                      - skill
                      - experience
                      - education
                      - certification
                      - project
                      example: skill
                    status:
                      type: string
                      enum:
                      - not_started
                      - in_progress
                      - completed
                      - cancelled
                      example: not_started
                    target_date:
                      type: string
                      format: date
                      example: '2026-06-30'
                    progress_percentage:
                      type: number
                      minimum: 0
                      maximum: 100
                      default: 0
                      example: 0
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      example: high
      responses:
        '201':
          description: Goal added successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  goal:
                    type: object
                    description: Employee goal with progress tracking. Progress updates
                      are returned as a sibling array in GET /goals/{id}, not nested
                      in the goal object.
                    properties:
                      id:
                        type: integer
                        example: 123
                      title:
                        type: string
                        example: Increase sales by 20%
                      description:
                        type: string
                        nullable: true
                        example: Achieve 20% growth in Q1 sales
                      goal_type:
                        type: string
                        enum:
                        - performance
                        - development
                        - behavior
                        - project
                        - skill
                        example: performance
                      goal_type_label:
                        type: string
                        nullable: true
                        description: Human-readable label for goal type
                      goal_category:
                        type: string
                        nullable: true
                        description: The focus area category key for this goal
                        example: professional_development
                      goal_category_label:
                        type: string
                        nullable: true
                        description: Human-readable label for the goal category
                        example: Professional Development
                      priority:
                        type: string
                        enum:
                        - low
                        - medium
                        - high
                        - critical
                        example: high
                      priority_label:
                        type: string
                        nullable: true
                        description: Human-readable label for priority
                      status:
                        type: string
                        enum:
                        - draft
                        - in_review
                        - active
                        - on_hold
                        - completed
                        - cancelled
                        - overdue
                        example: active
                      display_status:
                        type: string
                        nullable: true
                        description: Human-readable display status
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_date:
                        type: string
                        format: date
                        example: '2026-03-31'
                      completed_date:
                        type: string
                        format: date
                        nullable: true
                      progress_percentage:
                        type: number
                        minimum: 0
                        maximum: 100
                        example: 45.5
                      weight_percentage:
                        type: number
                        nullable: true
                        example: 30.0
                      workflow_stage:
                        type: string
                        enum:
                        - draft
                        - employee_review
                        - manager_finalized
                        - leadership_approved
                        description: Current workflow stage
                      workflow_stage_label:
                        type: string
                        nullable: true
                      is_smart_goal:
                        type: boolean
                        example: true
                      smart_score:
                        type: number
                        nullable: true
                      on_track:
                        type: boolean
                        nullable: true
                      days_until_due:
                        type: integer
                        nullable: true
                      is_overdue:
                        type: boolean
                        example: false
                      progress_update_allowed:
                        type: boolean
                        description: Whether progress updates can be submitted for
                          this goal
                      success_criteria:
                        type: string
                        nullable: true
                        description: Present when include_details is true (e.g. show
                          endpoint)
                        example: Reach $500K in sales
                      smart_criteria:
                        type: object
                        nullable: true
                        description: Present when include_details is true
                        properties:
                          is_specific:
                            type: boolean
                          is_measurable:
                            type: boolean
                          is_achievable:
                            type: boolean
                          is_relevant:
                            type: boolean
                          is_time_bound:
                            type: boolean
                      progress_updates_count:
                        type: integer
                        description: Present when include_details is true
                      latest_progress_update:
                        "$ref": "#/components/schemas/EPMSProgressUpdate"
                        nullable: true
                        description: Present when include_details is true
                      can_edit:
                        type: boolean
                        description: Present when include_details is true
                      can_complete:
                        type: boolean
                        description: Present when include_details is true
                      can_delete:
                        type: boolean
                        description: Present when include_details is true
                      requires_manager_approval:
                        type: boolean
                        description: Present when include_details is true
                      is_fully_approved:
                        type: boolean
                        description: Present when include_details is true
                      in_review_stage:
                        type: boolean
                        description: Present when include_details is true
                      can_cancel:
                        type: boolean
                        description: Present when include_details is true
                      can_put_on_hold:
                        type: boolean
                        description: Present when include_details is true
                      can_reactivate:
                        type: boolean
                        description: Present when include_details is true
                      can_send_to_employee:
                        type: boolean
                        description: Present when include_details is true
                      can_employee_review:
                        type: boolean
                        description: Present when include_details is true
                      can_manager_finalize:
                        type: boolean
                        description: Present when include_details is true
                      can_leadership_approve:
                        type: boolean
                        description: Present when include_details is true
                      manager_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      leadership_approved_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Present when include_details is true
                      employee:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                          email:
                            type: string
                            nullable: true
                            example: john@example.com
                          job_title:
                            type: string
                            nullable: true
                            description: 'The job title this business shows for the
                              person: their membership''s title (set by the profile''s
                              Job Title select), else the profile''s own title.'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
                  development_plan:
                    type: object
                    description: Employee development plan
                    properties:
                      id:
                        type: integer
                        example: 456
                      title:
                        type: string
                        example: Leadership Development Plan
                      description:
                        type: string
                        nullable: true
                        example: Focus on developing leadership skills
                      status:
                        type: string
                        enum:
                        - draft
                        - active
                        - completed
                        - cancelled
                        example: active
                      start_date:
                        type: string
                        format: date
                        example: '2026-01-01'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-12-31'
                      plan_type:
                        type: string
                        nullable: true
                        example: leadership
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      goals:
                        type: array
                        items:
                          "$ref": "#/components/schemas/EPMSGoal"
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings":
    get:
      tags:
      - EPMS Meetings
      summary: List meetings
      description: |
        Retrieves a list of meetings (1:1s, check-ins) with filtering options.

        **Required Scopes:** `read:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, managed, team, all)
        schema:
          type: string
          enum:
          - mine
          - managed
          - team
          - all
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - scheduled
          - confirmed
          - in_progress
          - completed
          - cancelled
          - rescheduled
      - name: meeting_type
        in: query
        description: Filter by meeting type
        schema:
          type: string
          enum:
          - performance_review
          - goal_discussion
          - feedback_session
          - check_in
          - development_planning
          - career_discussion
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: upcoming
        in: query
        description: Filter upcoming meetings only
        schema:
          type: boolean
      responses:
        '200':
          description: Meetings retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: One-on-one meeting or check-in
                      properties:
                        id:
                          type: integer
                          example: 567
                        title:
                          type: string
                          example: Q1 Check-in
                        meeting_type:
                          type: string
                          enum:
                          - performance_review
                          - goal_discussion
                          - feedback_session
                          - check_in
                          - development_planning
                          - career_discussion
                          example: check_in
                        status:
                          type: string
                          enum:
                          - scheduled
                          - confirmed
                          - in_progress
                          - completed
                          - cancelled
                          - rescheduled
                          example: scheduled
                        scheduled_at:
                          type: string
                          format: date-time
                          example: '2026-02-15T10:00:00Z'
                        duration_minutes:
                          type: integer
                          example: 30
                        location:
                          type: string
                          nullable: true
                          example: Conference Room A
                        description:
                          type: string
                          nullable: true
                          example: Quarterly performance check-in
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        manager:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: Jane Manager
                        notes:
                          type: string
                          nullable: true
                          example: Great discussion about goals and progress
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
                          example: '2026-02-15T10:30:00Z'
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Meetings
      summary: Create meeting
      description: |
        Schedules a new meeting (1:1 or check-in).

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - meeting
              properties:
                meeting:
                  type: object
                  description: Input schema for creating/updating meetings
                  required:
                  - title
                  - meeting_type
                  - employee_id
                  - scheduled_at
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 Check-in
                    meeting_type:
                      type: string
                      enum:
                      - performance_review
                      - goal_discussion
                      - feedback_session
                      - check_in
                      - development_planning
                      - career_discussion
                      example: check_in
                    employee_id:
                      type: integer
                      example: 456
                    scheduled_at:
                      type: string
                      format: date-time
                      example: '2026-02-15T10:00:00Z'
                    duration_minutes:
                      type: integer
                      default: 30
                      example: 30
                    location:
                      type: string
                      maxLength: 255
                      example: Conference Room A
                    description:
                      type: string
                      maxLength: 2000
                      example: Quarterly performance check-in
      responses:
        '201':
          description: Meeting created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings/{id}":
    get:
      tags:
      - EPMS Meetings
      summary: Get meeting details
      description: |
        Retrieves detailed meeting information.

        **Required Scopes:** `read:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      responses:
        '200':
          description: Meeting details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Meetings
      summary: Update meeting
      description: |
        Updates a meeting.

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - meeting
              properties:
                meeting:
                  type: object
                  description: Input schema for creating/updating meetings
                  required:
                  - title
                  - meeting_type
                  - employee_id
                  - scheduled_at
                  properties:
                    title:
                      type: string
                      minLength: 1
                      maxLength: 255
                      example: Q1 Check-in
                    meeting_type:
                      type: string
                      enum:
                      - performance_review
                      - goal_discussion
                      - feedback_session
                      - check_in
                      - development_planning
                      - career_discussion
                      example: check_in
                    employee_id:
                      type: integer
                      example: 456
                    scheduled_at:
                      type: string
                      format: date-time
                      example: '2026-02-15T10:00:00Z'
                    duration_minutes:
                      type: integer
                      default: 30
                      example: 30
                    location:
                      type: string
                      maxLength: 255
                      example: Conference Room A
                    description:
                      type: string
                      maxLength: 2000
                      example: Quarterly performance check-in
      responses:
        '200':
          description: Meeting updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - EPMS Meetings
      summary: Delete meeting
      description: |
        Permanently deletes a CANCELLED meeting. A scheduled or in-progress
        meeting must be cancelled first (`POST /epms/meetings/{id}/cancel`,
        which notifies the attendees); a completed meeting is a record and
        cannot be cancelled or deleted. Any other state answers 422
        `not_deletable`.

        Allowed for the people who may change the meeting: its organizer,
        whoever manages the employee, and whoever holds authority over its
        linked performance review.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      responses:
        '204':
          description: Meeting deleted successfully
        '422':
          description: The meeting is not cancelled, so it cannot be deleted (`not_deletable`)
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings/{id}/cancel":
    post:
      tags:
      - EPMS Meetings
      summary: Cancel meeting
      description: |
        Moves a meeting to `cancelled` and notifies its attendees. The meeting
        stays on record and can then be permanently deleted with
        `DELETE /epms/meetings/{id}`. A completed or already-cancelled meeting
        answers 422 `not_cancellable`.

        Allowed for the people who may change the meeting: its organizer,
        whoever manages the employee, and whoever holds authority over its
        linked performance review.

        **Required Scopes:** `destructive:epms`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:epms` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why the meeting is cancelled; included in the attendees'
                    notice
      responses:
        '200':
          description: Meeting cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
        '422':
          description: The meeting is completed or already cancelled (`not_cancellable`),
            or could not be saved (`cancel_failed`)
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/meetings/{id}/complete":
    post:
      tags:
      - EPMS Meetings
      summary: Complete meeting
      description: |
        Marks a meeting as complete with optional notes. Anyone who may open
        the meeting may complete it — the employee included. A completed or
        cancelled meeting answers 422.

        **Required Scopes:** `write:epms_meetings`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Meeting ID
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  description: Meeting completion notes
      responses:
        '200':
          description: Meeting completed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    type: object
                    description: One-on-one meeting or check-in
                    properties:
                      id:
                        type: integer
                        example: 567
                      title:
                        type: string
                        example: Q1 Check-in
                      meeting_type:
                        type: string
                        enum:
                        - performance_review
                        - goal_discussion
                        - feedback_session
                        - check_in
                        - development_planning
                        - career_discussion
                        example: check_in
                      status:
                        type: string
                        enum:
                        - scheduled
                        - confirmed
                        - in_progress
                        - completed
                        - cancelled
                        - rescheduled
                        example: scheduled
                      scheduled_at:
                        type: string
                        format: date-time
                        example: '2026-02-15T10:00:00Z'
                      duration_minutes:
                        type: integer
                        example: 30
                      location:
                        type: string
                        nullable: true
                        example: Conference Room A
                      description:
                        type: string
                        nullable: true
                        example: Quarterly performance check-in
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      manager:
                        type: object
                        nullable: true
                        properties:
                          id:
                            type: integer
                            example: 789
                          name:
                            type: string
                            example: Jane Manager
                      notes:
                        type: string
                        nullable: true
                        example: Great discussion about goals and progress
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2026-02-15T10:30:00Z'
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                  message:
                    type: string
                    example: Meeting has been completed
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_frameworks":
    get:
      tags:
      - EPMS Competency Frameworks
      summary: List competency frameworks
      description: |
        Retrieves a list of competency frameworks. Read-only for most users.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: framework_type
        in: query
        description: Filter by framework type
        schema:
          type: string
          enum:
          - core
          - leadership
          - technical
          - functional
          - role_specific
      - name: search
        in: query
        description: Search by name or description
        schema:
          type: string
      responses:
        '200':
          description: Competency frameworks retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Competency framework with defined competencies
                      properties:
                        id:
                          type: integer
                          example: 678
                        name:
                          type: string
                          example: Leadership Competencies
                        description:
                          type: string
                          nullable: true
                          example: Core leadership competencies for management roles
                        framework_type:
                          type: string
                          enum:
                          - core
                          - leadership
                          - technical
                          - functional
                          - role_specific
                          example: leadership
                        competencies:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Communication
                              description:
                                type: string
                                nullable: true
                                example: Ability to communicate effectively
                              level:
                                type: string
                                enum:
                                - beginner
                                - intermediate
                                - advanced
                                - expert
                                nullable: true
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-01T10:00:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_frameworks/{id}":
    get:
      tags:
      - EPMS Competency Frameworks
      summary: Get competency framework details
      description: |
        Retrieves detailed framework information with competencies.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Competency framework ID
        schema:
          type: integer
      responses:
        '200':
          description: Competency framework details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_framework:
                    type: object
                    description: Competency framework with defined competencies
                    properties:
                      id:
                        type: integer
                        example: 678
                      name:
                        type: string
                        example: Leadership Competencies
                      description:
                        type: string
                        nullable: true
                        example: Core leadership competencies for management roles
                      framework_type:
                        type: string
                        enum:
                        - core
                        - leadership
                        - technical
                        - functional
                        - role_specific
                        example: leadership
                      competencies:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Communication
                            description:
                              type: string
                              nullable: true
                              example: Ability to communicate effectively
                            level:
                              type: string
                              enum:
                              - beginner
                              - intermediate
                              - advanced
                              - expert
                              nullable: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-01T10:00:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_assessments":
    get:
      tags:
      - EPMS Competency Assessments
      summary: List competency assessments
      description: |
        Retrieves a list of competency assessments with filtering options.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: scope
        in: query
        description: Filter by scope (mine, team)
        schema:
          type: string
          enum:
          - mine
          - team
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - draft
          - in_progress
          - completed
          - approved
          - cancelled
      - name: employee_id
        in: query
        description: Filter by employee ID
        schema:
          type: integer
      - name: competency_framework_id
        in: query
        description: Filter by framework ID
        schema:
          type: integer
      responses:
        '200':
          description: Competency assessments retrieved successfully
          headers:
            X-Total-Count:
              schema:
                type: integer
            X-Total-Pages:
              schema:
                type: integer
            X-Current-Page:
              schema:
                type: integer
            X-Per-Page:
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      description: Competency assessment with ratings
                      properties:
                        id:
                          type: integer
                          example: 789
                        employee:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 456
                            name:
                              type: string
                              example: John Doe
                        competency_framework:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 678
                            name:
                              type: string
                              example: Leadership Competencies
                        assessment_type:
                          type: string
                          enum:
                          - self_assessment
                          - manager_assessment
                          - peer_assessment
                          - 360_assessment
                          example: manager_assessment
                        status:
                          type: string
                          enum:
                          - draft
                          - in_progress
                          - completed
                          example: completed
                        assessed_at:
                          type: string
                          format: date
                          example: '2026-01-15'
                        due_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-01-31'
                        overall_score:
                          type: number
                          nullable: true
                          minimum: 0
                          maximum: 5
                          example: 4.2
                        overall_comments:
                          type: string
                          nullable: true
                          example: Strong performance across all competencies
                        competency_ratings:
                          type: array
                          items:
                            type: object
                            properties:
                              competency_id:
                                type: integer
                                example: 1
                              competency_name:
                                type: string
                                example: Communication
                              rating_value:
                                type: number
                                minimum: 0
                                maximum: 5
                                example: 4.5
                              comments:
                                type: string
                                nullable: true
                                example: Excellent communication skills
                        created_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2026-01-15T14:30:00Z'
                  meta:
                    type: object
                    description: Pagination metadata
                    properties:
                      total_count:
                        type: integer
                        description: Total number of items
                        example: 150
                      total_pages:
                        type: integer
                        description: Total number of pages
                        example: 6
                      current_page:
                        type: integer
                        description: Current page number
                        example: 1
                      per_page:
                        type: integer
                        description: Items per page
                        example: 25
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - EPMS Competency Assessments
      summary: Create competency assessment
      description: |
        Creates a new competency assessment with ratings.

        **Required Scopes:** `write:epms_competencies`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - competency_assessment
              properties:
                competency_assessment:
                  type: object
                  description: Input schema for creating/updating competency assessments
                  required:
                  - employee_id
                  - competency_framework_id
                  properties:
                    employee_id:
                      type: integer
                      example: 456
                    competency_framework_id:
                      type: integer
                      example: 678
                    assessment_type:
                      type: string
                      enum:
                      - self_assessment
                      - manager_assessment
                      - peer_assessment
                      - 360_assessment
                      example: manager_assessment
                    assessed_at:
                      type: string
                      format: date
                      example: '2026-01-15'
                    due_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-01-31'
                    overall_score:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.2
                    overall_comments:
                      type: string
                      maxLength: 2000
                      example: Strong performance across all competencies
                    status:
                      type: string
                      enum:
                      - draft
                      - in_progress
                      - completed
                      example: completed
                    competency_ratings_attributes:
                      type: array
                      items:
                        type: object
                        required:
                        - competency_id
                        - rating_value
                        properties:
                          competency_id:
                            type: integer
                            example: 1
                          rating_value:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 4.5
                          comments:
                            type: string
                            maxLength: 1000
                            example: Excellent communication skills
      responses:
        '201':
          description: Competency assessment created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_assessment:
                    type: object
                    description: Competency assessment with ratings
                    properties:
                      id:
                        type: integer
                        example: 789
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      competency_framework:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 678
                          name:
                            type: string
                            example: Leadership Competencies
                      assessment_type:
                        type: string
                        enum:
                        - self_assessment
                        - manager_assessment
                        - peer_assessment
                        - 360_assessment
                        example: manager_assessment
                      status:
                        type: string
                        enum:
                        - draft
                        - in_progress
                        - completed
                        example: completed
                      assessed_at:
                        type: string
                        format: date
                        example: '2026-01-15'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-01-31'
                      overall_score:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.2
                      overall_comments:
                        type: string
                        nullable: true
                        example: Strong performance across all competencies
                      competency_ratings:
                        type: array
                        items:
                          type: object
                          properties:
                            competency_id:
                              type: integer
                              example: 1
                            competency_name:
                              type: string
                              example: Communication
                            rating_value:
                              type: number
                              minimum: 0
                              maximum: 5
                              example: 4.5
                            comments:
                              type: string
                              nullable: true
                              example: Excellent communication skills
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/epms/competency_assessments/{id}":
    get:
      tags:
      - EPMS Competency Assessments
      summary: Get competency assessment details
      description: |
        Retrieves detailed assessment information.

        **Required Scopes:** `read:epms_competencies`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Competency assessment ID
        schema:
          type: integer
      responses:
        '200':
          description: Competency assessment details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_assessment:
                    type: object
                    description: Competency assessment with ratings
                    properties:
                      id:
                        type: integer
                        example: 789
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      competency_framework:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 678
                          name:
                            type: string
                            example: Leadership Competencies
                      assessment_type:
                        type: string
                        enum:
                        - self_assessment
                        - manager_assessment
                        - peer_assessment
                        - 360_assessment
                        example: manager_assessment
                      status:
                        type: string
                        enum:
                        - draft
                        - in_progress
                        - completed
                        example: completed
                      assessed_at:
                        type: string
                        format: date
                        example: '2026-01-15'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-01-31'
                      overall_score:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.2
                      overall_comments:
                        type: string
                        nullable: true
                        example: Strong performance across all competencies
                      competency_ratings:
                        type: array
                        items:
                          type: object
                          properties:
                            competency_id:
                              type: integer
                              example: 1
                            competency_name:
                              type: string
                              example: Communication
                            rating_value:
                              type: number
                              minimum: 0
                              maximum: 5
                              example: 4.5
                            comments:
                              type: string
                              nullable: true
                              example: Excellent communication skills
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - EPMS Competency Assessments
      summary: Update competency assessment
      description: |
        Updates an assessment.

        **Required Scopes:** `write:epms_competencies` — and, on top of it,
        `destructive:epms` for `status: "cancelled"` on a draft or in-progress
        assessment, which retires it for good (owner decision 2026-10-07, #7182).

        An edit never approves: `status: "approved"` is refused with
        `422 not_approvable` — an assessment is approved by its approver once it
        is completed (#7249). Complete it with `POST …/submit` or
        `status: "completed"`. `PATCH` behaves identically.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Competency assessment ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - competency_assessment
              properties:
                competency_assessment:
                  type: object
                  description: Input schema for creating/updating competency assessments
                  required:
                  - employee_id
                  - competency_framework_id
                  properties:
                    employee_id:
                      type: integer
                      example: 456
                    competency_framework_id:
                      type: integer
                      example: 678
                    assessment_type:
                      type: string
                      enum:
                      - self_assessment
                      - manager_assessment
                      - peer_assessment
                      - 360_assessment
                      example: manager_assessment
                    assessed_at:
                      type: string
                      format: date
                      example: '2026-01-15'
                    due_date:
                      type: string
                      format: date
                      nullable: true
                      example: '2026-01-31'
                    overall_score:
                      type: number
                      minimum: 0
                      maximum: 5
                      example: 4.2
                    overall_comments:
                      type: string
                      maxLength: 2000
                      example: Strong performance across all competencies
                    status:
                      type: string
                      enum:
                      - draft
                      - in_progress
                      - completed
                      example: completed
                    competency_ratings_attributes:
                      type: array
                      items:
                        type: object
                        required:
                        - competency_id
                        - rating_value
                        properties:
                          competency_id:
                            type: integer
                            example: 1
                          rating_value:
                            type: number
                            minimum: 0
                            maximum: 5
                            example: 4.5
                          comments:
                            type: string
                            maxLength: 1000
                            example: Excellent communication skills
      responses:
        '200':
          description: Competency assessment updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  competency_assessment:
                    type: object
                    description: Competency assessment with ratings
                    properties:
                      id:
                        type: integer
                        example: 789
                      employee:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          name:
                            type: string
                            example: John Doe
                      competency_framework:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 678
                          name:
                            type: string
                            example: Leadership Competencies
                      assessment_type:
                        type: string
                        enum:
                        - self_assessment
                        - manager_assessment
                        - peer_assessment
                        - 360_assessment
                        example: manager_assessment
                      status:
                        type: string
                        enum:
                        - draft
                        - in_progress
                        - completed
                        example: completed
                      assessed_at:
                        type: string
                        format: date
                        example: '2026-01-15'
                      due_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-01-31'
                      overall_score:
                        type: number
                        nullable: true
                        minimum: 0
                        maximum: 5
                        example: 4.2
                      overall_comments:
                        type: string
                        nullable: true
                        example: Strong performance across all competencies
                      competency_ratings:
                        type: array
                        items:
                          type: object
                          properties:
                            competency_id:
                              type: integer
                              example: 1
                            competency_name:
                              type: string
                              example: Communication
                            rating_value:
                              type: number
                              minimum: 0
                              maximum: 5
                              example: 4.5
                            comments:
                              type: string
                              nullable: true
                              example: Excellent communication skills
                      created_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2026-01-15T14:30:00Z'
        '422':
          description: |
            The edit was refused:

            * a field failed validation — the body is the standard validation
              error list;
            * `not_approvable` — the edit set `status: "approved"`. An assessment
              is approved by its approver once it is completed, not by an edit
              (#7249).
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: object
                        properties:
                          field:
                            type: string
                            description: Field name with validation error
                          message:
                            type: string
                            description: Validation error message
                          code:
                            type: string
                            description: Always `validation_error`
                          field_name:
                            type: string
                            description: |
                              Present only when an error on `base` is about one named field — a
                              survey or form question left blank (`"<label> is required"`). The
                              question's key, as sent in `submission_data`.
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
              examples:
                not_approvable:
                  value:
                    error:
                      code: not_approvable
                      message: An assessment is approved by its approver once it is
                        completed, not by an edit.
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests":
    get:
      tags:
      - Leave Management
      summary: List user's leave requests
      description: |
        Get a paginated list of leave requests for the authenticated user.
        Supports filtering by status, leave type, and date ranges.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: status
        in: query
        description: Filter by status (comma-separated for multiple)
        schema:
          type: string
          example: pending,approved
      - name: leave_type_id
        in: query
        description: Filter by leave type ID
        schema:
          type: integer
          example: 1
      - name: start_date
        in: query
        description: Filter requests starting from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter requests ending before this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: period
        in: query
        description: Filter by time period
        schema:
          type: string
          enum:
          - upcoming
          - past
          - current
          example: upcoming
      responses:
        '200':
          description: Leave requests retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Basic leave request information
                        properties:
                          id:
                            type: integer
                            description: Unique leave request ID
                            example: 123
                          user_id:
                            type: integer
                            description: ID of the user who created the request
                            example: 456
                          leave_type:
                            "$ref": "#/components/schemas/LeaveTypeBasic"
                          start_date:
                            type: string
                            format: date
                            description: Start date of leave
                            example: '2024-03-15'
                          end_date:
                            type: string
                            format: date
                            description: End date of leave
                            example: '2024-03-17'
                          hours_calculated:
                            type: number
                            description: Total hours for this leave request (primary
                              field)
                            example: 24
                          business_days:
                            type: number
                            description: 'DEPRECATED: Use hours_calculated instead.
                              Returns same value as hours_calculated for backward
                              compatibility.'
                            example: 24
                          status:
                            type: string
                            enum:
                            - pending
                            - approved
                            - denied
                            - cancelled
                            - special_approval
                            description: Current status of the leave request
                            example: pending
                          notes:
                            type: string
                            nullable: true
                            description: Optional notes for the leave request
                            example: Family vacation
                          created_at:
                            type: string
                            format: date-time
                            description: When the request was created
                            example: '2024-02-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            description: When the request was last updated
                            example: '2024-02-15T10:00:00Z'
                        required:
                        - id
                        - user_id
                        - leave_type
                        - start_date
                        - end_date
                        - hours_calculated
                        - status
                        - created_at
                        - updated_at
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Leave Management
      summary: Create a new leave request
      description: |
        Create a new leave request for the authenticated user.
        The request will be validated against business rules, blackout periods, and coverage limits.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - leave_type_id
              - start_date
              - end_date
              properties:
                leave_type_id:
                  type: integer
                  description: ID of the leave type
                  example: 1
                start_date:
                  type: string
                  format: date
                  description: Start date of leave
                  example: '2024-03-15'
                end_date:
                  type: string
                  format: date
                  description: End date of leave
                  example: '2024-03-17'
                notes:
                  type: string
                  description: Optional notes for the leave request
                  example: Family vacation
                half_day:
                  type: boolean
                  description: Whether this is a half-day request
                  example: false
      responses:
        '201':
          description: Leave request created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/leave_requests/{id}":
    get:
      tags:
      - Leave Management
      summary: Get a specific leave request
      description: Get detailed information about a specific leave request
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Leave request retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Leave Management
      summary: Update a leave request
      description: |
        Update a leave request that is awaiting approval, or approved leave that
        has not started yet. A request that starts today or earlier, or whose
        schedule period has closed, cannot be modified (403 request_not_editable).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                leave_type_id:
                  type: integer
                  description: ID of the leave type
                  example: 1
                start_date:
                  type: string
                  format: date
                  description: Start date of leave
                  example: '2024-03-15'
                end_date:
                  type: string
                  format: date
                  description: End date of leave
                  example: '2024-03-17'
                notes:
                  type: string
                  description: Optional notes for the leave request
                  example: Updated vacation dates
                half_day:
                  type: boolean
                  description: Whether this is a half-day request
                  example: false
      responses:
        '200':
          description: Leave request updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
        '403':
          description: Leave request cannot be modified
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Leave Management
      summary: Delete a leave request
      description: |
        Delete a leave request that is still awaiting approval and has not
        started yet (its schedule period still open). Anything else is refused
        with 403 request_not_deletable — cancel it instead.

        Delete-level (owner decision 2026-10-04): requires the `destructive:leave_management`
        scope. A personal token cannot hold it: do this in MangoApps instead.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Leave request deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Leave request deleted successfully
        '403':
          description: Leave request cannot be deleted
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests/{id}/cancel":
    post:
      tags:
      - Leave Management
      summary: Cancel a leave request
      description: |
        Cancel a leave request. This action is different from delete and may restore leave balances.

        Delete-level (owner decision 2026-10-04): requires the `destructive:leave_management`
        scope. A personal token cannot hold it: do this in MangoApps instead.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave request ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Leave request cancelled successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_request:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveRequest"
                    - type: object
                      properties:
                        approval_details:
                          type: object
                          description: Approval workflow information
                          properties:
                            requires_approval:
                              type: boolean
                              description: Whether this request requires approval
                              example: true
                            approved_by:
                              type: object
                              nullable: true
                              description: User who approved the request
                              properties:
                                id:
                                  type: integer
                                  example: 789
                                name:
                                  type: string
                                  example: Jane Manager
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: When the request was approved
                              example: '2024-02-16T14:30:00Z'
                            denial_reason:
                              type: string
                              nullable: true
                              description: Reason for denial if applicable
                              example: Insufficient coverage during requested period
                        conflicts:
                          type: object
                          description: Shift conflict information
                          properties:
                            has_conflicts:
                              type: boolean
                              description: Whether this request conflicts with assigned
                                shifts
                              example: false
                            conflicting_shifts:
                              type: array
                              description: List of conflicting shifts
                              items:
                                "$ref": "#/components/schemas/ShiftConflict"
                        blackout_warnings:
                          type: array
                          description: Blackout period warnings
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Winter Holiday Season
                              start_date:
                                type: string
                                format: date
                                example: '2024-12-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-12-31'
                              blocks_requests:
                                type: boolean
                                example: true
                              message:
                                type: string
                                example: Leave requests are blocked during Winter
                                  Holiday Season
                        coverage_impact:
                          type: object
                          nullable: true
                          description: Coverage impact analysis
                          properties:
                            affects_coverage:
                              type: boolean
                              example: false
                            coverage_percentage:
                              type: number
                              example: 85.5
                            available_spots:
                              type: integer
                              example: 3
                            message:
                              type: string
                              example: This request will not affect coverage limits
                  message:
                    type: string
                    example: Leave request cancelled successfully
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests/conflicts":
    get:
      tags:
      - Leave Management
      summary: Check for shift conflicts
      description: |
        Check if a proposed leave request would conflict with assigned shifts.
        This is typically used before creating a leave request to warn users.
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        required: true
        description: Proposed start date
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: end_date
        in: query
        required: true
        description: Proposed end date
        schema:
          type: string
          format: date
          example: '2024-03-17'
      responses:
        '200':
          description: Conflict check completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  conflicts:
                    type: array
                    items:
                      type: object
                      description: Information about a conflicting shift
                      properties:
                        id:
                          type: integer
                          description: Shift ID
                          example: 789
                        name:
                          type: string
                          description: Shift name
                          example: Morning Shift
                        start_time:
                          type: string
                          format: date-time
                          description: Shift start time
                          example: '2024-03-15T08:00:00Z'
                        end_time:
                          type: string
                          format: date-time
                          description: Shift end time
                          example: '2024-03-15T16:00:00Z'
                        location:
                          type: object
                          nullable: true
                          description: Shift location
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Main Office
                      required:
                      - id
                      - name
                      - start_time
                      - end_time
                  has_conflicts:
                    type: boolean
                    example: false
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_requests/history":
    get:
      tags:
      - Leave Management
      summary: Get leave request history with status changes
      description: Get detailed history of leave requests including all status changes
        and approval workflow.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        description: Number of items per page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: start_date
        in: query
        description: 'Filter history from this date (default: 12 months ago)'
        schema:
          type: string
          format: date
          example: '2023-03-01'
      - name: end_date
        in: query
        description: 'Filter history until this date (default: today)'
        schema:
          type: string
          format: date
          example: '2024-03-01'
      responses:
        '200':
          description: Leave request history retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        allOf:
                        - "$ref": "#/components/schemas/LeaveRequestDetailed"
                        - type: object
                          properties:
                            status_history:
                              type: array
                              description: Complete status change history
                              items:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                    example: 1
                                  from_status:
                                    type: string
                                    example: pending
                                  to_status:
                                    type: string
                                    example: approved
                                  changed_by:
                                    type: object
                                    nullable: true
                                    description: Who made the change; null for an
                                      automatic change (see `automatic`).
                                    properties:
                                      id:
                                        type: integer
                                        example: 2
                                      name:
                                        type: string
                                        example: Manager Name
                                  automatic:
                                    type: boolean
                                    description: True for a change no person made
                                      — hours recounted after a holiday or working-day
                                      change, or approved leave cancelled when the
                                      employee left.
                                    example: false
                                  notes:
                                    type: string
                                    nullable: true
                                    example: Approved for vacation
                                  changed_at:
                                    type: string
                                    format: date-time
                                    example: '2024-02-16T14:30:00Z'
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/leave_requests/analytics":
    get:
      tags:
      - Leave Management
      summary: Get leave usage analytics
      description: Get comprehensive analytics about leave usage patterns, trends,
        and statistics.
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        description: 'Analytics period start date (default: beginning of year)'
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: 'Analytics period end date (default: end of year)'
        schema:
          type: string
          format: date
          example: '2024-12-31'
      responses:
        '200':
          description: Leave analytics retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Comprehensive leave usage analytics
                properties:
                  period:
                    type: object
                    description: Analytics period
                    properties:
                      start_date:
                        type: string
                        format: date
                        example: '2024-01-01'
                      end_date:
                        type: string
                        format: date
                        example: '2024-12-31'
                      total_days:
                        type: integer
                        example: 366
                  summary:
                    type: object
                    description: Overall summary statistics
                    properties:
                      total_requests:
                        type: integer
                        example: 25
                      total_leave_days:
                        type: integer
                        example: 75
                      approved_requests:
                        type: integer
                        example: 20
                      approved_days:
                        type: integer
                        example: 60
                      pending_requests:
                        type: integer
                        example: 3
                      special_approval_requests:
                        type: integer
                        example: 1
                      denied_requests:
                        type: integer
                        example: 1
                      cancelled_requests:
                        type: integer
                        example: 1
                  by_leave_type:
                    type: array
                    description: Analytics grouped by leave type
                    items:
                      type: object
                      properties:
                        leave_type:
                          "$ref": "#/components/schemas/LeaveTypeBasic"
                        total_requests:
                          type: integer
                          example: 15
                        total_days:
                          type: integer
                          example: 45
                        approved_requests:
                          type: integer
                          example: 12
                        approved_days:
                          type: integer
                          example: 36
                        pending_requests:
                          type: integer
                          example: 2
                        special_approval_requests:
                          type: integer
                          example: 1
                        denied_requests:
                          type: integer
                          example: 1
                        cancelled_requests:
                          type: integer
                          example: 0
                  by_month:
                    type: array
                    description: Analytics grouped by month
                    items:
                      type: object
                      properties:
                        month:
                          type: string
                          example: 2024-03
                        month_name:
                          type: string
                          example: March 2024
                        total_requests:
                          type: integer
                          example: 5
                        total_days:
                          type: integer
                          example: 15
                        approved_days:
                          type: integer
                          example: 12
                  by_status:
                    type: array
                    description: Analytics grouped by status
                    items:
                      type: object
                      properties:
                        status:
                          type: string
                          example: approved
                        count:
                          type: integer
                          example: 20
                        total_days:
                          type: integer
                          example: 60
                        percentage:
                          type: number
                          example: 80.0
                  usage_patterns:
                    type: object
                    description: Usage patterns and insights
                    properties:
                      most_used_leave_type:
                        type: object
                        nullable: true
                        description: Leave type with most usage
                      busiest_month:
                        type: object
                        nullable: true
                        description: Month with most leave days
                      average_request_length:
                        type: number
                        description: Average length of leave requests in days
                        example: 3.0
                      longest_request:
                        type: object
                        nullable: true
                        description: Longest leave request
                      approval_rate:
                        type: number
                        description: Percentage of requests approved
                        example: 80.0
                required:
                - period
                - summary
                - by_leave_type
                - by_month
                - by_status
                - usage_patterns
  "/leave_requests/bulk":
    post:
      tags:
      - Leave Management
      summary: Create multiple leave requests
      description: Create multiple leave requests in a single operation. Maximum 10
        requests per batch.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - leave_requests
              properties:
                leave_requests:
                  type: array
                  maxItems: 10
                  items:
                    type: object
                    required:
                    - leave_type_id
                    - start_date
                    - end_date
                    properties:
                      leave_type_id:
                        type: integer
                        example: 1
                      start_date:
                        type: string
                        format: date
                        example: '2024-03-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-03-17'
                      notes:
                        type: string
                        example: Vacation
                      half_day:
                        type: boolean
                        example: false
      responses:
        '201':
          description: Bulk leave requests processed
          content:
            application/json:
              schema:
                type: object
                description: Response from bulk leave request operation
                properties:
                  results:
                    type: array
                    description: Successful requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 0
                        success:
                          type: boolean
                          example: true
                        leave_request:
                          "$ref": "#/components/schemas/LeaveRequestDetailed"
                  errors:
                    type: array
                    description: Failed requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 1
                        success:
                          type: boolean
                          example: false
                        errors:
                          type: array
                          items:
                            type: string
                          example:
                          - Start date cannot be in the past
                  summary:
                    type: object
                    description: Summary of bulk operation
                    properties:
                      total_requests:
                        type: integer
                        example: 5
                      successful:
                        type: integer
                        example: 4
                      failed:
                        type: integer
                        example: 1
                required:
                - results
                - errors
                - summary
        '422':
          description: Some or all requests failed validation
          content:
            application/json:
              schema:
                type: object
                description: Response from bulk leave request operation
                properties:
                  results:
                    type: array
                    description: Successful requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 0
                        success:
                          type: boolean
                          example: true
                        leave_request:
                          "$ref": "#/components/schemas/LeaveRequestDetailed"
                  errors:
                    type: array
                    description: Failed requests
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of request in original array
                          example: 1
                        success:
                          type: boolean
                          example: false
                        errors:
                          type: array
                          items:
                            type: string
                          example:
                          - Start date cannot be in the past
                  summary:
                    type: object
                    description: Summary of bulk operation
                    properties:
                      total_requests:
                        type: integer
                        example: 5
                      successful:
                        type: integer
                        example: 4
                      failed:
                        type: integer
                        example: 1
                required:
                - results
                - errors
                - summary
  "/leave_balances":
    get:
      tags:
      - Leave Management
      summary: Get user's leave balances
      description: |
        Get leave balances for all leave types available to the user.
        Includes accrued, used, available amounts and usage percentages.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Leave balances retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Leave balance information for a specific leave
                          type. All numeric values are in HOURS.
                        properties:
                          leave_type_id:
                            type: integer
                            description: ID of the leave type
                            example: 1
                          leave_type:
                            "$ref": "#/components/schemas/LeaveTypeBasic"
                          year:
                            type: integer
                            description: Year for this balance
                            example: 2024
                          unit:
                            type: string
                            description: Unit of measurement for balance values
                            example: hours
                            enum:
                            - hours
                          accrued:
                            type: number
                            description: Total hours accrued for the year
                            example: 120.0
                          used:
                            type: number
                            description: Hours already used
                            example: 40.0
                          balance:
                            type: number
                            description: Available hours remaining
                            example: 80.0
                          pending:
                            type: number
                            description: Hours in pending requests
                            example: 24.0
                          percentages:
                            type: object
                            description: Usage percentages for progress indicators
                            properties:
                              used:
                                type: number
                                description: Percentage of accrued hours used
                                example: 33.3
                              pending:
                                type: number
                                description: Percentage of accrued hours pending
                                example: 20.0
                              available:
                                type: number
                                description: Percentage of accrued hours available
                                example: 46.7
                          source:
                            "$ref": "#/components/schemas/ManagedSource"
                        required:
                        - leave_type_id
                        - leave_type
                        - year
                        - accrued
                        - used
                        - balance
                        - pending
                        - percentages
        '304':
          "$ref": "#/components/responses/NotModified"
  "/leave_balances/{leave_type_id}":
    get:
      tags:
      - Leave Management
      summary: Get balance for specific leave type
      description: Get detailed balance information for a specific leave type
      security:
      - BearerAuth: []
      parameters:
      - name: leave_type_id
        in: path
        required: true
        description: Leave type ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Leave balance retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_balance:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveBalance"
                    - type: object
                      properties:
                        recent_usage:
                          type: array
                          description: Leave consumed against this balance in the
                            last 6 months, newest first. Includes approved leave requests,
                            usage an administrator recorded by hand, and hours paid
                            out from banked time — all three move the balance's `used`
                            figure. Pending requests are NOT included (they are not
                            usage yet); read the balance's `pending` hours for those.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                description: Identifier within the table named by
                                  `record_type`.
                                example: 123
                              record_type:
                                type: string
                                description: Which record `id` refers to. `leave_request`
                                  for an approved request; `leave_ledger_entry` for
                                  administrator- recorded usage or a banked-time payout.
                                enum:
                                - leave_request
                                - leave_ledger_entry
                                example: leave_request
                              source:
                                type: string
                                enum:
                                - leave_request
                                - manual
                                - payout
                                example: leave_request
                              start_date:
                                type: string
                                format: date
                                example: '2024-01-15'
                              end_date:
                                type: string
                                format: date
                                example: '2024-01-17'
                              hours:
                                type: number
                                description: Hours actually charged to the balance
                                  (the paid portion). Use this figure to reconcile
                                  against `used`.
                                example: 16
                              unpaid_hours:
                                type: number
                                description: Portion taken as unpaid leave, which
                                  never touches the balance.
                                example: 8
                              hours_calculated:
                                type: number
                                description: Total hours on the request, including
                                  any unpaid portion. Equals `hours` unless the request
                                  has an unpaid split.
                                example: 24
                              business_days:
                                type: number
                                description: 'DEPRECATED: Use hours_calculated'
                                example: 24
                              status:
                                type: string
                                nullable: true
                                description: Always `approved` for a leave request;
                                  null for ledger entries.
                                example: approved
                              upcoming:
                                type: boolean
                                description: Approved but the time off has not happened
                                  yet.
                                example: false
                              counts_toward_balance:
                                type: boolean
                                description: False for a historical record imported
                                  alongside the balance figures its hours are already
                                  inside. Such rows are listed but excluded from `used`.
                                example: true
                              notes:
                                type: string
                                nullable: true
                                example: Vacation
                              edited:
                                type: boolean
                                description: The leave request has been modified since
                                  it was submitted.
                                example: false
                              edit_count:
                                type: integer
                                example: 0
                              last_edited_at:
                                type: string
                                format: date-time
                                nullable: true
                                example: '2024-02-01T09:30:00Z'
                        accrual_policy:
                          type: object
                          nullable: true
                          description: Accrual policy information
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Standard Vacation Policy
                            accrual_rate:
                              type: number
                              description: Hours accrued per period
                              example: 3.08
                            accrual_unit:
                              type: string
                              description: Unit for all accrual values
                              example: hours
                              enum:
                              - hours
                            accrual_frequency:
                              type: string
                              example: bi-weekly
                            max_balance:
                              type: number
                              description: Maximum balance cap in hours
                              nullable: true
                              example: 120.0
                            max_accrual:
                              type: number
                              description: Deprecated, use max_balance
                              nullable: true
                              example: 120.0
                            carryover_allowed:
                              type: boolean
                              example: true
                            carryover_max:
                              type: number
                              description: Maximum hours to carry over
                              nullable: true
                              example: 40.0
                            waiting_period_days:
                              type: integer
                              description: Calendar days before accrual starts
                              example: 90
                            proration_enabled:
                              type: boolean
                              example: true
                        projected_balance:
                          type: number
                          description: Projected balance (hours) at end of year
                          example: 100.0
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/leave_balances/summary":
    get:
      tags:
      - Leave Management
      summary: Get leave balance summary
      description: |
        Get a comprehensive summary of all leave balances including totals,
        upcoming leave, and usage analytics.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Leave balance summary retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Comprehensive leave balance summary
                properties:
                  totals:
                    type: object
                    description: Totals across all leave types
                    properties:
                      accrued:
                        type: number
                        example: 25.0
                      used:
                        type: number
                        example: 8.0
                      pending:
                        type: number
                        example: 3.0
                      available:
                        type: number
                        example: 14.0
                      usage_percentage:
                        type: number
                        example: 32.0
                  by_leave_type:
                    type: array
                    description: Balances by leave type
                    items:
                      "$ref": "#/components/schemas/LeaveBalance"
                  upcoming_leave:
                    type: array
                    description: Upcoming approved leave
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          example: 123
                        leave_type:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Vacation
                            color:
                              type: string
                              example: "#4CAF50"
                        start_date:
                          type: string
                          format: date
                          example: '2024-04-15'
                        end_date:
                          type: string
                          format: date
                          example: '2024-04-17'
                        hours:
                          type: number
                          description: Hours that will be charged to the balance (the
                            paid portion).
                          example: 16
                        unpaid_hours:
                          type: number
                          description: Portion to be taken as unpaid leave, which
                            never touches the balance.
                          example: 8
                        hours_calculated:
                          type: number
                          description: Total hours on the request, including any unpaid
                            portion. Equals `hours` unless the request has an unpaid
                            split.
                          example: 24
                        business_days:
                          type: number
                          description: 'DEPRECATED: Use hours_calculated'
                          example: 24
                        notes:
                          type: string
                          nullable: true
                          example: Spring vacation
                  year:
                    type: integer
                    description: Year for this summary
                    example: 2024
                required:
                - totals
                - by_leave_type
                - upcoming_leave
                - year
  "/leave_types":
    get:
      tags:
      - Leave Management
      summary: List available leave types
      description: |
        Get all active leave types available to the user's business.
        Optionally include current balance information.
      security:
      - BearerAuth: []
      parameters:
      - name: include_balances
        in: query
        description: Include current balance information
        schema:
          type: boolean
          default: false
          example: true
      responses:
        '200':
          description: Leave types retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Leave type information
                        properties:
                          id:
                            type: integer
                            description: Unique leave type ID
                            example: 1
                          name:
                            type: string
                            description: Name of the leave type
                            example: Vacation
                          color:
                            type: string
                            description: Color code for UI display
                            example: "#4CAF50"
                          icon:
                            type: string
                            description: Icon class for UI display
                            example: fas fa-calendar-star
                          active:
                            type: boolean
                            description: Whether this leave type is active
                            example: true
                          default:
                            type: boolean
                            description: Whether this is the default leave type
                            example: false
                          current_balance:
                            type: object
                            nullable: true
                            description: Current balance for this leave type (if requested).
                              Hour figures are strings with two decimals.
                            properties:
                              accrued:
                                type: number
                                example: 15.0
                              used:
                                type: number
                                example: 5.0
                              balance:
                                type: number
                                example: 10.0
                              balance_hours:
                                type: string
                                description: The ledger balance (accrued − used).
                                  It still counts hours waiting for a decision — show
                                  available_to_request_hours as what a new request
                                  can use.
                                example: '10.00'
                              pending_hours:
                                type: string
                                description: Hours in this type's requests that are
                                  waiting for a decision.
                                example: '4.00'
                              available_to_request_hours:
                                type: string
                                description: Hours a new request can draw today —
                                  the ledger balance less pending requests, the figure
                                  the server books a request against (the request
                                  form's "hours left to request").
                                example: '6.00'
                              year:
                                type: integer
                                example: 2024
                        required:
                        - id
                        - name
                        - color
                        - icon
                        - active
                        - default
        '304':
          "$ref": "#/components/responses/NotModified"
  "/leave_types/{id}":
    get:
      tags:
      - Leave Management
      summary: Get specific leave type details
      description: Get detailed information about a specific leave type including
        policies and rules
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Leave type ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Leave type retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  leave_type:
                    allOf:
                    - "$ref": "#/components/schemas/LeaveType"
                    - type: object
                      properties:
                        description:
                          type: string
                          nullable: true
                          description: Description of the leave type
                          example: Annual vacation leave for rest and recreation
                        advance_notice_days:
                          type: integer
                          description: Required advance notice in days
                          example: 7
                        documentation_required:
                          type: boolean
                          description: Whether documentation is required
                          example: false
                        documentation_threshold_days:
                          type: integer
                          description: Threshold for requiring documentation
                          example: 3
                        requires_approval:
                          type: boolean
                          description: Whether requests require approval
                          example: true
                        deduct_from_balance:
                          type: boolean
                          description: Whether to deduct from balance
                          example: true
                        allow_negative_balance:
                          type: boolean
                          description: Whether negative balances are allowed
                          example: false
                        accrual_enabled:
                          type: boolean
                          description: Whether accrual is enabled
                          example: true
                        accrual_policy:
                          type: object
                          nullable: true
                          description: Associated accrual policy
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Standard Vacation Policy
                            accrual_rate:
                              type: number
                              example: 1.25
                            accrual_frequency:
                              type: string
                              example: monthly
                            accrual_frequency_display:
                              type: string
                              example: Monthly
                            max_accrual:
                              type: number
                              nullable: true
                              example: 30.0
                            carryover_allowed:
                              type: boolean
                              example: true
                            carryover_max:
                              type: number
                              nullable: true
                              example: 5.0
                            waiting_period_days:
                              type: integer
                              example: 90
                            proration_enabled:
                              type: boolean
                              example: true
                        business_rules:
                          type: object
                          description: Business rules and policies
                          properties:
                            advance_notice_required:
                              type: boolean
                              example: true
                            documentation_rules:
                              type: object
                              properties:
                                required:
                                  type: boolean
                                  example: false
                                threshold_days:
                                  type: integer
                                  example: 3
                            approval_workflow:
                              type: object
                              properties:
                                requires_approval:
                                  type: boolean
                                  example: true
                                auto_approve_threshold:
                                  type: number
                                  nullable: true
                                  example:
                            balance_rules:
                              type: object
                              properties:
                                deduct_from_balance:
                                  type: boolean
                                  example: true
                                allow_negative:
                                  type: boolean
                                  example: false
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/holidays":
    get:
      tags:
      - Leave Management
      summary: List business holidays
      description: |
        List holidays, with optional filtering by year, date range, or upcoming holidays.
        With `location_id`, the holidays that apply to that location: its own, its
        parent location's, its jurisdictions' (with their parents, e.g. state and
        country) and company-wide ones. Without it, an admin or an integration (a
        service account) reads every holiday in the business; anyone else reads their
        own — those that apply to their location, in the same way. `scope=mine` asks
        for the caller's own holidays whoever they are, admins included.
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: query
        description: Filter holidays by year
        schema:
          type: integer
          example: 2024
      - name: start_date
        in: query
        description: Filter holidays from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter holidays until this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: upcoming
        in: query
        description: Only show upcoming holidays
        schema:
          type: boolean
          example: true
      - name: location_id
        in: query
        description: The holidays that apply to that location — its own, its parent's,
          its jurisdictions' and company-wide ones. 404 `location_not_found` when
          the location is not in this business.
        schema:
          type: integer
          example: 12
      - name: type
        in: query
        description: Only holidays of this kind.
        schema:
          type: string
          enum:
          - business
          - jurisdiction
          - location
      - name: scope
        in: query
        description: "`mine` — the caller's own holidays, for every caller, admins
          included: those that apply to their primary location (else their default
          location), else the company-wide ones when they have neither. The same answer
          a member's call without it gives. Ignored when `location_id` is given; any
          other value is ignored."
        schema:
          type: string
          enum:
          - mine
      responses:
        '200':
          description: Holidays retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Business holiday information
                        properties:
                          id:
                            type: integer
                            description: Unique holiday ID
                            example: 1
                          name:
                            type: string
                            description: Holiday name
                            example: New Year's Day
                          date:
                            type: string
                            format: date
                            description: Holiday date
                            example: '2024-01-01'
                          description:
                            type: string
                            nullable: true
                            description: Holiday description
                            example: New Year's Day celebration
                          recurring:
                            type: boolean
                            description: Whether this holiday recurs annually
                            example: true
                          business_id:
                            type: integer
                            description: Business ID
                            example: 1
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-01T00:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-01T00:00:00Z'
                        required:
                        - id
                        - name
                        - date
                        - business_id
                        - created_at
                        - updated_at
                    returned_count:
                      type: integer
                      description: Rows in `items`. At most 500 holidays are returned;
                        `total_count` is the true number in the window.
                    truncated:
                      type: boolean
                      description: True when the window holds more than the 500 rows
                        returned (`total_count` > `returned_count`).
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: "`invalid_date_range` — `start_date` or `end_date` is not a
            date."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`location_not_found` — `location_id` is not a location in
            this business."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/holidays/year/{year}":
    get:
      tags:
      - Leave Management
      summary: Get holidays for specific year
      description: |
        A year's holidays. An admin or an integration (a service account) reads every
        holiday in the business; anyone else reads those that apply to their location
        — its own, its jurisdictions' and the company's. `scope=mine` asks for the
        caller's own holidays whoever they are, admins included.
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: path
        required: true
        description: Year to get holidays for
        schema:
          type: integer
          example: 2024
      - name: scope
        in: query
        description: "`mine` — the caller's own holidays, for every caller, admins
          included: those that apply to their primary location (else their default
          location), else the company-wide ones. Any other value is ignored."
        schema:
          type: string
          enum:
          - mine
      responses:
        '200':
          description: Holidays for year retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  year:
                    type: integer
                    example: 2024
                  holidays:
                    type: array
                    items:
                      type: object
                      description: Business holiday information
                      properties:
                        id:
                          type: integer
                          description: Unique holiday ID
                          example: 1
                        name:
                          type: string
                          description: Holiday name
                          example: New Year's Day
                        date:
                          type: string
                          format: date
                          description: Holiday date
                          example: '2024-01-01'
                        description:
                          type: string
                          nullable: true
                          description: Holiday description
                          example: New Year's Day celebration
                        recurring:
                          type: boolean
                          description: Whether this holiday recurs annually
                          example: true
                        business_id:
                          type: integer
                          description: Business ID
                          example: 1
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                      required:
                      - id
                      - name
                      - date
                      - business_id
                      - created_at
                      - updated_at
                  total_count:
                    type: integer
                    example: 12
                  returned_count:
                    type: integer
                    description: Rows in `holidays`. At most 500 are returned; `total_count`
                      is the true number in the year.
                  truncated:
                    type: boolean
                    description: True when the year holds more than the 500 rows returned.
        '400':
          description: "`invalid_year` — the year is outside 1900–2100."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/holidays/{id}":
    get:
      tags:
      - Leave Management
      summary: Get specific holiday details
      description: Get detailed information about a specific holiday
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Holiday ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Holiday retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  holiday:
                    allOf:
                    - "$ref": "#/components/schemas/Holiday"
                    - type: object
                      properties:
                        day_of_week:
                          type: string
                          description: Day of the week
                          example: Monday
                        week_of_year:
                          type: integer
                          description: Week number of the year
                          example: 1
                        month_name:
                          type: string
                          description: Month name
                          example: January
                        is_weekend:
                          type: boolean
                          description: Whether the holiday falls on a weekend
                          example: false
                        days_until:
                          type: integer
                          nullable: true
                          description: Days until this holiday (null if past)
                          example: 30
                        metadata:
                          type: object
                          description: Additional holiday metadata
                          properties:
                            year:
                              type: integer
                              example: 2024
                            month:
                              type: integer
                              example: 1
                            day:
                              type: integer
                              example: 1
                            quarter:
                              type: integer
                              example: 1
                            is_past:
                              type: boolean
                              example: false
                            is_today:
                              type: boolean
                              example: false
                            is_future:
                              type: boolean
                              example: true
  "/blackout_periods":
    get:
      tags:
      - Leave Management
      summary: List blackout periods
      description: 'Get blackout periods with optional filtering by status, date range,
        leave type, or location.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Filter by status
        schema:
          type: string
          enum:
          - active
          - upcoming
          - past
          example: active
      - name: start_date
        in: query
        description: Filter periods overlapping from this date
        schema:
          type: string
          format: date
          example: '2024-01-01'
      - name: end_date
        in: query
        description: Filter periods overlapping until this date
        schema:
          type: string
          format: date
          example: '2024-12-31'
      - name: leave_type_id
        in: query
        description: Filter by leave type
        schema:
          type: integer
          example: 1
      - name: location_id
        in: query
        description: Filter by location
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Blackout periods retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        description: Leave blackout period information
                        properties:
                          id:
                            type: integer
                            description: Unique blackout period ID
                            example: 1
                          name:
                            type: string
                            description: Blackout period name
                            example: Winter Holiday Season
                          description:
                            type: string
                            nullable: true
                            description: Blackout period description
                            example: No leave allowed during winter holidays
                          start_date:
                            type: string
                            format: date
                            description: Start date of blackout period
                            example: '2024-12-15'
                          end_date:
                            type: string
                            format: date
                            description: End date of blackout period
                            example: '2024-12-31'
                          block_requests:
                            type: boolean
                            description: Whether requests are completely blocked or
                              require special approval
                            example: true
                          status:
                            type: string
                            description: Current status of the blackout period
                            example: upcoming
                          duration_days:
                            type: integer
                            description: Duration in days
                            example: 17
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-01T00:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-01T00:00:00Z'
                        required:
                        - id
                        - name
                        - start_date
                        - end_date
                        - block_requests
                        - status
                        - duration_days
        '304':
          "$ref": "#/components/responses/NotModified"
  "/blackout_periods/upcoming":
    get:
      tags:
      - Leave Management
      summary: Get upcoming blackout periods
      description: Get upcoming blackout periods with optional limit
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        description: Maximum number of periods to return
        schema:
          type: integer
          default: 10
          example: 5
      responses:
        '200':
          description: Upcoming blackout periods retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  upcoming_blackouts:
                    type: array
                    items:
                      type: object
                      description: Leave blackout period information
                      properties:
                        id:
                          type: integer
                          description: Unique blackout period ID
                          example: 1
                        name:
                          type: string
                          description: Blackout period name
                          example: Winter Holiday Season
                        description:
                          type: string
                          nullable: true
                          description: Blackout period description
                          example: No leave allowed during winter holidays
                        start_date:
                          type: string
                          format: date
                          description: Start date of blackout period
                          example: '2024-12-15'
                        end_date:
                          type: string
                          format: date
                          description: End date of blackout period
                          example: '2024-12-31'
                        block_requests:
                          type: boolean
                          description: Whether requests are completely blocked or
                            require special approval
                          example: true
                        status:
                          type: string
                          description: Current status of the blackout period
                          example: upcoming
                        duration_days:
                          type: integer
                          description: Duration in days
                          example: 17
                        created_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          example: '2024-01-01T00:00:00Z'
                      required:
                      - id
                      - name
                      - start_date
                      - end_date
                      - block_requests
                      - status
                      - duration_days
                  total_count:
                    type: integer
                    example: 3
  "/blackout_periods/current":
    get:
      tags:
      - Leave Management
      summary: Get current active blackout periods
      description: Get all currently active blackout periods
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current blackout periods retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_blackouts:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/BlackoutPeriod"
                      - type: object
                        properties:
                          leave_types:
                            type: array
                            description: Leave types affected by this blackout
                            items:
                              "$ref": "#/components/schemas/LeaveTypeBasic"
                          locations:
                            type: array
                            description: Locations affected by this blackout
                            items:
                              type: object
                              properties:
                                id:
                                  type: integer
                                  example: 1
                                name:
                                  type: string
                                  example: Main Office
                                address:
                                  type: string
                                  example: 123 Main St
                          applies_to_all_leave_types:
                            type: boolean
                            description: Whether this applies to all leave types
                            example: true
                          applies_to_all_locations:
                            type: boolean
                            description: Whether this applies to all locations
                            example: true
                          days_until_start:
                            type: integer
                            nullable: true
                            description: Days until blackout starts (null if past)
                            example: 30
                          days_until_end:
                            type: integer
                            nullable: true
                            description: Days until blackout ends (null if past)
                            example: 47
                          is_current:
                            type: boolean
                            description: Whether this blackout is currently active
                            example: false
                          is_upcoming:
                            type: boolean
                            description: Whether this blackout is upcoming
                            example: true
                          is_past:
                            type: boolean
                            description: Whether this blackout is in the past
                            example: false
                          created_by:
                            type: object
                            nullable: true
                            description: User who created this blackout period
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Admin User
                          impact_message:
                            type: string
                            description: Message describing the impact of this blackout
                            example: Leave requests are completely blocked during
                              this period
                  total_count:
                    type: integer
                    example: 1
                  has_active_blackouts:
                    type: boolean
                    example: true
  "/blackout_periods/{id}":
    get:
      tags:
      - Leave Management
      summary: Get specific blackout period details
      description: Get detailed information about a specific blackout period
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Blackout period ID
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Blackout period retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  blackout_period:
                    allOf:
                    - "$ref": "#/components/schemas/BlackoutPeriod"
                    - type: object
                      properties:
                        leave_types:
                          type: array
                          description: Leave types affected by this blackout
                          items:
                            "$ref": "#/components/schemas/LeaveTypeBasic"
                        locations:
                          type: array
                          description: Locations affected by this blackout
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Main Office
                              address:
                                type: string
                                example: 123 Main St
                        applies_to_all_leave_types:
                          type: boolean
                          description: Whether this applies to all leave types
                          example: true
                        applies_to_all_locations:
                          type: boolean
                          description: Whether this applies to all locations
                          example: true
                        days_until_start:
                          type: integer
                          nullable: true
                          description: Days until blackout starts (null if past)
                          example: 30
                        days_until_end:
                          type: integer
                          nullable: true
                          description: Days until blackout ends (null if past)
                          example: 47
                        is_current:
                          type: boolean
                          description: Whether this blackout is currently active
                          example: false
                        is_upcoming:
                          type: boolean
                          description: Whether this blackout is upcoming
                          example: true
                        is_past:
                          type: boolean
                          description: Whether this blackout is in the past
                          example: false
                        created_by:
                          type: object
                          nullable: true
                          description: User who created this blackout period
                          properties:
                            id:
                              type: integer
                              example: 1
                            name:
                              type: string
                              example: Admin User
                        impact_message:
                          type: string
                          description: Message describing the impact of this blackout
                          example: Leave requests are completely blocked during this
                            period
  "/leave_coverage/availability":
    get:
      tags:
      - Leave Management
      summary: Check leave availability for specific date
      description: 'Check if leave is available for a specific date, considering coverage
        limits and business rules.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: date
        in: query
        required: true
        description: Date to check availability for
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      - name: leave_type_id
        in: query
        description: Leave type to check
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Availability check completed
          content:
            application/json:
              schema:
                type: object
                description: Leave availability check result
                properties:
                  date:
                    type: string
                    format: date
                    description: Date checked
                    example: '2024-03-15'
                  available:
                    type: boolean
                    description: Whether leave is available
                    example: true
                  unlimited:
                    type: boolean
                    description: Whether coverage is unlimited
                    example: false
                  current_off:
                    type: integer
                    description: Number of people currently off
                    example: 2
                  max_allowed:
                    type: integer
                    nullable: true
                    description: Maximum people allowed off (null if unlimited)
                    example: 5
                  available_spots:
                    type: integer
                    nullable: true
                    description: Available spots remaining (null if unlimited)
                    example: 3
                  limit_type:
                    type: string
                    description: Type of coverage limit
                    example: percentage
                  message:
                    type: string
                    description: Human-readable availability message
                    example: 3 spots available out of 5 maximum
                  metadata:
                    type: object
                    description: Additional date metadata
                    properties:
                      day_of_week:
                        type: string
                        example: Friday
                      is_weekend:
                        type: boolean
                        example: false
                      is_holiday:
                        type: boolean
                        example: false
                      is_blackout_period:
                        type: boolean
                        example: false
                required:
                - date
                - available
                - unlimited
                - current_off
                - message
                - metadata
  "/leave_coverage/date_range":
    get:
      tags:
      - Leave Management
      summary: Check availability for date range
      description: 'Check leave availability for a range of dates with summary statistics.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        required: true
        description: Start date of range
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: end_date
        in: query
        required: true
        description: End date of range
        schema:
          type: string
          format: date
          example: '2024-03-17'
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      - name: leave_type_id
        in: query
        description: Leave type to check
        schema:
          type: integer
          example: 1
      - name: exclude_request_id
        in: query
        description: Your own leave request being edited; its days are not counted
          as taken
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Date range availability check completed
          content:
            application/json:
              schema:
                type: object
                description: Leave coverage for date range
                properties:
                  start_date:
                    type: string
                    format: date
                    example: '2024-03-15'
                  end_date:
                    type: string
                    format: date
                    example: '2024-03-17'
                  total_days:
                    type: integer
                    description: Total days in range
                    example: 3
                  coverage_data:
                    type: array
                    description: Coverage data for each day
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaveCoverageAvailability"
                      - type: object
                        properties:
                          week_of_year:
                            type: integer
                            example: 11
                          month:
                            type: integer
                            example: 3
                  summary:
                    type: object
                    description: Summary statistics for the range
                    properties:
                      total_days:
                        type: integer
                        example: 3
                      available_days:
                        type: integer
                        example: 2
                      blocked_days:
                        type: integer
                        example: 1
                      weekend_days:
                        type: integer
                        example: 0
                      holiday_days:
                        type: integer
                        example: 0
                      blackout_days:
                        type: integer
                        example: 1
                      availability_percentage:
                        type: number
                        example: 66.7
                required:
                - start_date
                - end_date
                - total_days
                - coverage_data
                - summary
  "/leave_coverage/alternatives":
    get:
      tags:
      - Leave Management
      summary: Find alternative dates for leave request
      description: 'Find alternative date ranges when the requested dates are not
        available.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: start_date
        in: query
        required: true
        description: Original start date
        schema:
          type: string
          format: date
          example: '2024-03-15'
      - name: end_date
        in: query
        required: true
        description: Original end date
        schema:
          type: string
          format: date
          example: '2024-03-17'
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      - name: leave_type_id
        in: query
        description: Leave type to check
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Alternative dates found
          content:
            application/json:
              schema:
                type: object
                description: Alternative date suggestions
                properties:
                  original_request:
                    type: object
                    description: Original requested dates
                    properties:
                      start_date:
                        type: string
                        format: date
                        example: '2024-03-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-03-17'
                      duration_days:
                        type: integer
                        example: 3
                  alternatives:
                    type: array
                    description: Alternative date ranges
                    items:
                      type: object
                      properties:
                        start_date:
                          type: string
                          format: date
                          example: '2024-03-22'
                        end_date:
                          type: string
                          format: date
                          example: '2024-03-24'
                        duration_days:
                          type: integer
                          example: 3
                        days_from_original:
                          type: integer
                          description: Days difference from original start date
                          example: 7
                        has_weekends:
                          type: boolean
                          example: true
                        has_holidays:
                          type: boolean
                          example: false
                        business_days:
                          type: integer
                          example: 3
                  total_alternatives:
                    type: integer
                    description: Number of alternatives found
                    example: 5
                  search_period:
                    type: object
                    description: Period searched for alternatives
                    properties:
                      start_date:
                        type: string
                        format: date
                        example: '2024-03-15'
                      end_date:
                        type: string
                        format: date
                        example: '2024-05-14'
                required:
                - original_request
                - alternatives
                - total_alternatives
                - search_period
  "/leave_coverage/calendar/{year}/{month}":
    get:
      tags:
      - Leave Management
      summary: Get calendar view of leave coverage
      description: 'Get a monthly calendar view showing leave availability, holidays,
        and blackout periods.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: year
        in: path
        required: true
        description: Year for calendar
        schema:
          type: integer
          example: 2024
      - name: month
        in: path
        required: true
        description: Month for calendar (1-12)
        schema:
          type: integer
          minimum: 1
          maximum: 12
          example: 3
      - name: location_id
        in: query
        description: Location to check coverage for
        schema:
          type: integer
          example: 1
      responses:
        '200':
          description: Calendar data retrieved successfully
          content:
            application/json:
              schema:
                type: object
                description: Monthly calendar view of leave coverage
                properties:
                  year:
                    type: integer
                    example: 2024
                  month:
                    type: integer
                    example: 3
                  month_name:
                    type: string
                    example: March
                  days_in_month:
                    type: integer
                    example: 31
                  calendar_data:
                    type: array
                    description: Data for each day of the month
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/LeaveCoverageAvailability"
                      - type: object
                        properties:
                          day_of_month:
                            type: integer
                            example: 15
                          day_of_week_short:
                            type: string
                            example: Fri
                          is_today:
                            type: boolean
                            example: false
                          holiday:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Good Friday
                              description:
                                type: string
                                example: Christian holiday
                          blackout_period:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                                example: 1
                              name:
                                type: string
                                example: Spring Break
                              blocks_requests:
                                type: boolean
                                example: true
                          week_of_month:
                            type: integer
                            example: 3
                  summary:
                    type: object
                    description: Monthly summary statistics
                    properties:
                      total_days:
                        type: integer
                        example: 31
                      available_days:
                        type: integer
                        example: 25
                      blocked_days:
                        type: integer
                        example: 6
                      weekend_days:
                        type: integer
                        example: 8
                      holiday_days:
                        type: integer
                        example: 2
                      blackout_days:
                        type: integer
                        example: 4
                required:
                - year
                - month
                - month_name
                - days_in_month
                - calendar_data
                - summary
  "/user_availabilities":
    get:
      tags:
      - Availability
      summary: List user availabilities and leave blocks
      description: "Get a list of user availability blocks and leave requests for
        a specific week. \nReturns availability data along with leave blocks (approved
        and pending), week \nconfirmation status, and calendar settings. This matches
        the web UI display \nwhere both availability and leave blocks are shown together.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: Week start date (defaults to current week)
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Availabilities and leaves retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availabilities:
                    type: array
                    description: User availability blocks for the week
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
                  leaves:
                    type: array
                    description: Leave requests (approved and pending) for the week
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          description: Leave request ID
                          example: 123
                        start_date:
                          type: string
                          format: date
                          description: Leave start date
                          example: '2025-10-15'
                        end_date:
                          type: string
                          format: date
                          description: Leave end date
                          example: '2025-10-15'
                        leave_type:
                          type: string
                          description: Name of the leave type
                          example: Vacation
                        status:
                          type: string
                          description: Leave request status
                          enum:
                          - approved
                          - pending
                          example: approved
                        reason:
                          type: string
                          description: Reason for leave
                          example: Personal time off
                  week_start:
                    type: string
                    format: date
                    example: '2025-10-13'
                  week_end:
                    type: string
                    format: date
                    example: '2025-10-19'
                  week_confirmed:
                    type: boolean
                    example: false
                  calendar_settings:
                    type: object
                    properties:
                      start_time:
                        type: integer
                        example: 6
                      end_time:
                        type: integer
                        example: 21
                      minimum_block_minutes:
                        type: integer
                        example: 60
                  total_count:
                    type: integer
                    description: Total count of availability blocks
                    example: 5
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Availability
      summary: Create user availability
      description: |
        Create a new availability block for a specific date and time range.
        Date must be editable (not in past or confirmed week).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - specific_date
              - start_time
              - end_time
              properties:
                specific_date:
                  type: string
                  format: date
                  example: '2025-10-15'
                start_time:
                  type: string
                  format: time
                  example: '09:00'
                end_time:
                  type: string
                  format: time
                  example: '17:00'
                notes:
                  type: string
                  example: Available for morning shift
      responses:
        '201':
          description: Availability created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availability:
                    type: object
                    description: User availability block information
                    properties:
                      id:
                        type: integer
                        description: Unique availability ID
                        example: 123
                      specific_date:
                        type: string
                        format: date
                        description: Date for this availability block
                        example: '2025-10-15'
                      start_time:
                        type: string
                        format: time
                        description: Start time (displayed in user's timezone)
                        example: '09:00'
                      end_time:
                        type: string
                        format: time
                        description: End time (displayed in user's timezone)
                        example: '17:00'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about this availability
                        example: Available for morning shift
                      store_in_utc:
                        type: boolean
                        description: Whether times are stored in UTC
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: When this availability was created
                        example: '2025-10-01T10:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this availability was last updated
                        example: '2025-10-01T10:30:00Z'
                    required:
                    - id
                    - specific_date
                    - start_time
                    - end_time
                  message:
                    type: string
                    example: Availability created successfully
        '422':
          description: Validation error or duplicate availability
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/weekly":
    get:
      tags:
      - Availability
      summary: Get weekly availability summary
      description: "Get a comprehensive weekly view including availabilities, leaves,
        \nconfirmation status, and total hours for a specific week.\n"
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: Week start date (defaults to current week)
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Weekly availability retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  week_start:
                    type: string
                    format: date
                    example: '2025-10-13'
                  week_end:
                    type: string
                    format: date
                    example: '2025-10-19'
                  availabilities:
                    type: array
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
                  leaves:
                    type: array
                    items:
                      type: object
                      description: Basic leave request information
                      properties:
                        id:
                          type: integer
                          description: Unique leave request ID
                          example: 123
                        user_id:
                          type: integer
                          description: ID of the user who created the request
                          example: 456
                        leave_type:
                          "$ref": "#/components/schemas/LeaveTypeBasic"
                        start_date:
                          type: string
                          format: date
                          description: Start date of leave
                          example: '2024-03-15'
                        end_date:
                          type: string
                          format: date
                          description: End date of leave
                          example: '2024-03-17'
                        hours_calculated:
                          type: number
                          description: Total hours for this leave request (primary
                            field)
                          example: 24
                        business_days:
                          type: number
                          description: 'DEPRECATED: Use hours_calculated instead.
                            Returns same value as hours_calculated for backward compatibility.'
                          example: 24
                        status:
                          type: string
                          enum:
                          - pending
                          - approved
                          - denied
                          - cancelled
                          - special_approval
                          description: Current status of the leave request
                          example: pending
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes for the leave request
                          example: Family vacation
                        created_at:
                          type: string
                          format: date-time
                          description: When the request was created
                          example: '2024-02-15T10:00:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When the request was last updated
                          example: '2024-02-15T10:00:00Z'
                      required:
                      - id
                      - user_id
                      - leave_type
                      - start_date
                      - end_date
                      - hours_calculated
                      - status
                      - created_at
                      - updated_at
                  confirmed:
                    type: boolean
                    example: false
                  confirmed_at:
                    type: string
                    format: date-time
                    nullable: true
                  total_hours:
                    type: number
                    format: float
                    example: 40.0
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/bulk_create":
    post:
      tags:
      - Availability
      summary: Bulk create user availabilities
      description: "Create multiple availability blocks in a single request. \nReturns
        success and failure results for each item.\n"
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - availabilities
              properties:
                availabilities:
                  type: array
                  items:
                    type: object
                    required:
                    - specific_date
                    - start_time
                    - end_time
                    properties:
                      specific_date:
                        type: string
                        format: date
                      start_time:
                        type: string
                        format: time
                      end_time:
                        type: string
                        format: time
                      notes:
                        type: string
      responses:
        '200':
          description: Bulk operation completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: object
                    properties:
                      success:
                        type: array
                        items:
                          type: object
                          description: User availability block information
                          properties:
                            id:
                              type: integer
                              description: Unique availability ID
                              example: 123
                            specific_date:
                              type: string
                              format: date
                              description: Date for this availability block
                              example: '2025-10-15'
                            start_time:
                              type: string
                              format: time
                              description: Start time (displayed in user's timezone)
                              example: '09:00'
                            end_time:
                              type: string
                              format: time
                              description: End time (displayed in user's timezone)
                              example: '17:00'
                            notes:
                              type: string
                              nullable: true
                              description: Optional notes about this availability
                              example: Available for morning shift
                            store_in_utc:
                              type: boolean
                              description: Whether times are stored in UTC
                              example: true
                            created_at:
                              type: string
                              format: date-time
                              description: When this availability was created
                              example: '2025-10-01T10:30:00Z'
                            updated_at:
                              type: string
                              format: date-time
                              description: When this availability was last updated
                              example: '2025-10-01T10:30:00Z'
                          required:
                          - id
                          - specific_date
                          - start_time
                          - end_time
                      failed:
                        type: array
                        items:
                          type: object
                          properties:
                            date:
                              type: string
                            error:
                              type: string
                  success_count:
                    type: integer
                  failed_count:
                    type: integer
                  message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/mark_available_all_week":
    post:
      tags:
      - Availability
      summary: Mark available for entire week
      description: |
        Mark the user as available for an entire week by creating availability blocks
        for each day. Each day it fills has its existing availability replaced; the days
        it skips keep theirs. The requested window is kept, rounded inward to whole hours,
        and split into blocks of the business's minimum block size from its start; a
        remainder shorter than one block is not created.

        Days with approved leave are automatically skipped. Past dates within the week
        are also skipped. Weekends are excluded by default unless `include_weekends` is true.

        For the current day, if the requested start time has already passed, availability
        begins from the next hour.
      operationId: markAvailableAllWeekUserAvailabilities
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                week_start:
                  type: string
                  format: date
                  description: Start date of the week (YYYY-MM-DD). Defaults to the
                    current week.
                  example: '2026-03-02'
                start_time:
                  type: string
                  description: Daily start time in HH:MM format. Defaults to "00:00".
                  example: '08:00'
                end_time:
                  type: string
                  description: Daily end time in HH:MM format. Defaults to "23:59".
                  example: '17:00'
                include_weekends:
                  type: boolean
                  description: Whether to include Saturday and Sunday. Defaults to
                    false.
                  default: false
                  example: false
      responses:
        '200':
          description: Week availability created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Human-readable success message
                    example: Successfully marked available for the week of March 02,
                      2026.
                  week_start:
                    type: string
                    format: date
                    description: Start date of the week
                    example: '2026-03-02'
                  week_end:
                    type: string
                    format: date
                    description: End date of the week
                    example: '2026-03-08'
                  created_count:
                    type: integer
                    description: Number of new availability blocks created
                    example: 30
                  cleared_count:
                    type: integer
                    description: Number of existing blocks that were cleared
                    example: 0
                  skipped_leave_count:
                    type: integer
                    description: Number of days skipped due to approved leave
                    example: 0
                  availabilities:
                    type: array
                    description: All availability blocks for the week after the operation
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
        '400':
          description: Invalid date or time format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                invalid_date:
                  summary: Invalid date format
                  value:
                    error:
                      code: invalid_date
                      message: Invalid week_start date format
                invalid_time:
                  summary: Invalid time format
                  value:
                    error:
                      code: invalid_time
                      message: Invalid start_time or end_time format. Use HH:MM.
        '422':
          description: Week is confirmed or in the past
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                week_confirmed:
                  summary: Week is confirmed
                  value:
                    error:
                      code: week_confirmed
                      message: Cannot modify availability for confirmed weeks.
                past_week:
                  summary: Week is in the past
                  value:
                    error:
                      code: past_week
                      message: Cannot modify availability for past weeks.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/clear_week":
    delete:
      tags:
      - Availability
      summary: Clear all availability for a week
      description: |
        Delete all availability blocks for the specified week. The week must not be
        confirmed and must not be in the past. Use this to reset a user's availability
        for an entire week in a single operation.
      operationId: clearWeekUserAvailabilities
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: |
          Start date of the week to clear (YYYY-MM-DD). Defaults to the current
          business week if not provided.
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Week availability cleared successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    description: Human-readable success message
                    example: Successfully cleared 5 availability blocks for the week
                      of October 13, 2025.
                  cleared_count:
                    type: integer
                    description: Number of availability blocks that were deleted
                    example: 5
                  week_start:
                    type: string
                    format: date
                    description: Start date of the cleared week
                    example: '2025-10-13'
                  week_end:
                    type: string
                    format: date
                    description: End date of the cleared week
                    example: '2025-10-19'
        '400':
          description: Invalid date format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: invalid_date
                  message: Invalid week_start date format
        '404':
          description: No availability blocks found for the week
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: no_availability
                  message: No availability blocks found for the week of October 13,
                    2025.
        '422':
          description: Week is confirmed or in the past
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                week_confirmed:
                  summary: Week is confirmed
                  value:
                    error:
                      code: week_confirmed
                      message: Cannot clear availability for confirmed weeks. Unconfirm
                        the week first.
                past_week:
                  summary: Week is in the past
                  value:
                    error:
                      code: past_week
                      message: Cannot clear availability for past weeks.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/copy_week_to_week":
    post:
      tags:
      - Availability
      summary: Copy availability from one week to another
      description: |
        Copy all availability blocks from a source week to a target week. Existing
        availability in the target week is preserved — duplicate blocks are skipped
        rather than overwritten.

        Days with approved leave in the target week are automatically skipped.
        The target week must not be confirmed.
      operationId: copyWeekToWeekUserAvailabilities
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - source_week_start
              - target_week_start
              properties:
                source_week_start:
                  type: string
                  format: date
                  description: Start date of the source week to copy from (YYYY-MM-DD).
                  example: '2026-03-02'
                target_week_start:
                  type: string
                  format: date
                  description: Start date of the target week to copy to (YYYY-MM-DD).
                  example: '2026-03-09'
      responses:
        '200':
          description: Availability copied successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    description: Whether the operation succeeded
                    example: true
                  message:
                    type: string
                    description: Human-readable success message
                    example: Successfully copied 5 availability blocks
                  copied_count:
                    type: integer
                    description: Number of availability blocks copied
                    example: 5
                  duplicate_count:
                    type: integer
                    description: Number of blocks skipped because they already existed
                      in the target week
                    example: 0
                  skipped_leave_count:
                    type: integer
                    description: Number of blocks skipped because of approved leave
                      in the target week
                    example: 1
                  source_week_start:
                    type: string
                    format: date
                    description: Start date of the source week
                    example: '2026-03-02'
                  target_week_start:
                    type: string
                    format: date
                    description: Start date of the target week
                    example: '2026-03-09'
                  availabilities:
                    type: array
                    description: All availability blocks for the target week after
                      the operation
                    items:
                      type: object
                      description: User availability block information
                      properties:
                        id:
                          type: integer
                          description: Unique availability ID
                          example: 123
                        specific_date:
                          type: string
                          format: date
                          description: Date for this availability block
                          example: '2025-10-15'
                        start_time:
                          type: string
                          format: time
                          description: Start time (displayed in user's timezone)
                          example: '09:00'
                        end_time:
                          type: string
                          format: time
                          description: End time (displayed in user's timezone)
                          example: '17:00'
                        notes:
                          type: string
                          nullable: true
                          description: Optional notes about this availability
                          example: Available for morning shift
                        store_in_utc:
                          type: boolean
                          description: Whether times are stored in UTC
                          example: true
                        created_at:
                          type: string
                          format: date-time
                          description: When this availability was created
                          example: '2025-10-01T10:30:00Z'
                        updated_at:
                          type: string
                          format: date-time
                          description: When this availability was last updated
                          example: '2025-10-01T10:30:00Z'
                      required:
                      - id
                      - specific_date
                      - start_time
                      - end_time
        '400':
          description: Invalid date format
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              example:
                error:
                  code: invalid_date
                  message: Invalid source_week_start date format. Use YYYY-MM-DD.
        '422':
          description: Validation error (same week, confirmed, or past)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
              examples:
                same_week:
                  summary: Source and target are the same week
                  value:
                    error:
                      code: same_week
                      message: Source and target weeks must be different.
                week_confirmed:
                  summary: Target week is confirmed
                  value:
                    error:
                      code: week_confirmed
                      message: Cannot copy availability to a confirmed week. Unconfirm
                        the target week first.
                past_week:
                  summary: Target week is in the past
                  value:
                    error:
                      code: past_week
                      message: Cannot copy availability to a past week.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/user_availabilities/{id}":
    get:
      tags:
      - Availability
      summary: Get specific availability
      description: Get details of a specific availability block
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Availability retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availability:
                    type: object
                    description: User availability block information
                    properties:
                      id:
                        type: integer
                        description: Unique availability ID
                        example: 123
                      specific_date:
                        type: string
                        format: date
                        description: Date for this availability block
                        example: '2025-10-15'
                      start_time:
                        type: string
                        format: time
                        description: Start time (displayed in user's timezone)
                        example: '09:00'
                      end_time:
                        type: string
                        format: time
                        description: End time (displayed in user's timezone)
                        example: '17:00'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about this availability
                        example: Available for morning shift
                      store_in_utc:
                        type: boolean
                        description: Whether times are stored in UTC
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: When this availability was created
                        example: '2025-10-01T10:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this availability was last updated
                        example: '2025-10-01T10:30:00Z'
                    required:
                    - id
                    - specific_date
                    - start_time
                    - end_time
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - Availability
      summary: Update user availability
      description: 'Update an existing availability block. Date must still be editable.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                start_time:
                  type: string
                  format: time
                  example: '09:00'
                end_time:
                  type: string
                  format: time
                  example: '17:00'
                notes:
                  type: string
                  example: Updated availability
      responses:
        '200':
          description: Availability updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  availability:
                    type: object
                    description: User availability block information
                    properties:
                      id:
                        type: integer
                        description: Unique availability ID
                        example: 123
                      specific_date:
                        type: string
                        format: date
                        description: Date for this availability block
                        example: '2025-10-15'
                      start_time:
                        type: string
                        format: time
                        description: Start time (displayed in user's timezone)
                        example: '09:00'
                      end_time:
                        type: string
                        format: time
                        description: End time (displayed in user's timezone)
                        example: '17:00'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about this availability
                        example: Available for morning shift
                      store_in_utc:
                        type: boolean
                        description: Whether times are stored in UTC
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        description: When this availability was created
                        example: '2025-10-01T10:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this availability was last updated
                        example: '2025-10-01T10:30:00Z'
                    required:
                    - id
                    - specific_date
                    - start_time
                    - end_time
                  message:
                    type: string
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error or date not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Availability
      summary: Delete user availability
      description: 'Delete an availability block. Date must still be editable.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Availability deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  id:
                    type: string
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Date not editable
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_availability_confirmations/status":
    get:
      tags:
      - Availability
      summary: Get weekly confirmation status
      description: 'Check if a specific week''s availability has been confirmed by
        the user.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: week_start
        in: query
        description: Week start date (defaults to current week)
        schema:
          type: string
          format: date
          example: '2025-10-13'
      responses:
        '200':
          description: Confirmation status retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  week_start:
                    type: string
                    format: date
                  week_end:
                    type: string
                    format: date
                  confirmed:
                    type: boolean
                  confirmed_at:
                    type: string
                    format: date-time
                    nullable: true
                  notes:
                    type: string
                    nullable: true
                  auto_generated:
                    type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/weekly_availability_confirmations":
    post:
      tags:
      - Availability
      summary: Confirm weekly availability
      description: |
        Confirm user's availability for a specific week. This locks the week from editing
        and signals to managers that the schedule is final.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - week_start
              properties:
                week_start:
                  type: string
                  format: date
                  example: '2025-10-13'
                notes:
                  type: string
                  example: Confirmed for this week
      responses:
        '201':
          description: Availability confirmed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  confirmation:
                    type: object
                    description: Weekly availability confirmation information
                    properties:
                      id:
                        type: integer
                        description: Unique confirmation ID
                        example: 456
                      start_date:
                        type: string
                        format: date
                        description: Week start date
                        example: '2025-10-13'
                      end_date:
                        type: string
                        format: date
                        description: Week end date
                        example: '2025-10-19'
                      confirmed_at:
                        type: string
                        format: date-time
                        description: When the availability was confirmed
                        example: '2025-10-12T15:30:00Z'
                      notes:
                        type: string
                        nullable: true
                        description: Optional notes about the confirmation
                        example: Confirmed for this week
                      auto_generated:
                        type: boolean
                        description: Whether this was auto-generated by the system
                        example: false
                      created_at:
                        type: string
                        format: date-time
                        description: When this confirmation was created
                        example: '2025-10-12T15:30:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        description: When this confirmation was last updated
                        example: '2025-10-12T15:30:00Z'
                    required:
                    - id
                    - start_date
                    - end_date
                    - confirmed_at
                    - auto_generated
                  message:
                    type: string
        '422':
          description: Cannot confirm past week or validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - Availability
      summary: Unconfirm weekly availability
      description: |
        Remove the confirmation for a specific week, allowing the user to edit their availability again.
        Cannot unconfirm past weeks.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - week_start
              properties:
                week_start:
                  type: string
                  format: date
                  example: '2025-10-13'
      responses:
        '200':
          description: Confirmation removed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  week_start:
                    type: string
        '422':
          description: Cannot unconfirm past week
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills":
    get:
      tags:
      - Skills & Certifications
      summary: List available skills
      description: |
        Get a list of skills available in the business. Supports filtering, searching, and sorting.

        **Use Cases:**
        - Browse skills for adding to employee profile
        - Search for specific skills
        - Filter by category or certification requirements

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: search
        in: query
        description: Search skills by name or description
        schema:
          type: string
          example: javascript
      - name: category
        in: query
        description: Filter by skill category
        schema:
          type: string
          example: technical_skills
      - name: requires_certification
        in: query
        description: Filter by certification requirement
        schema:
          type: boolean
          example: true
      - name: sort_by
        in: query
        description: Sort skills by field
        schema:
          type: string
          enum:
          - name
          - category
          - popularity
          example: name
      - name: sort_order
        in: query
        description: Sort direction. For `name`/`category` the default is ascending.
          For `popularity`, results default to most-popular-first; `sort_order=asc`
          lists least-popular first.
        schema:
          type: string
          enum:
          - asc
          - desc
          default: asc
          example: desc
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          example: 1
      - name: per_page
        in: query
        description: Number of items per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          example: 25
      - name: detailed
        in: query
        description: 'When `true`, each item additionally includes `employee_count`
          (number of active employees holding the skill), `average_proficiency`, `metadata`,
          and `help_desk_tier`.

          '
        schema:
          type: boolean
          example: true
      responses:
        '200':
          description: Skills retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        oneOf:
                        - type: object
                          properties:
                            id:
                              type: integer
                              example: 123
                            name:
                              type: string
                              example: JavaScript Development
                            description:
                              type: string
                              nullable: true
                              example: Frontend and backend JavaScript programming
                            category:
                              type: string
                              nullable: true
                              example: technical_skills
                            category_display:
                              type: string
                              nullable: true
                              example: Technical Skills
                            requires_certification:
                              type: boolean
                              example: true
                            skill_kind:
                              type: string
                              enum:
                              - skill
                              - certification
                              - license
                              description: 'Catalog credential type. Clients map `license`
                                -> LIC badge, `certification` -> CERT badge, `skill`
                                -> no badge.

                                '
                              example: certification
                            requires_document_upload:
                              type: boolean
                              example: false
                            active:
                              type: boolean
                              example: true
                            created_at:
                              type: string
                              format: date-time
                              example: '2024-01-15T10:00:00Z'
                            updated_at:
                              type: string
                              format: date-time
                              example: '2024-01-15T10:00:00Z'
                        - allOf:
                          - "$ref": "#/components/schemas/Skill"
                          - type: object
                            properties:
                              metadata:
                                type: object
                                nullable: true
                                description: Additional skill metadata
                              help_desk_tier:
                                type: integer
                                nullable: true
                                example: 2
                              employee_count:
                                type: integer
                                example: 15
                              average_proficiency:
                                type: number
                                format: float
                                nullable: true
                                example: 3.4
                        description: "`SkillDetailed` (with `employee_count`) is returned
                          when `?detailed=true`; otherwise the base `Skill` schema.\n"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/{id}":
    get:
      tags:
      - Skills & Certifications
      summary: Get skill details
      description: |
        Get detailed information about a specific skill

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Skill ID
        schema:
          type: integer
          example: 123
      responses:
        '200':
          description: Skill details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  skill:
                    allOf:
                    - "$ref": "#/components/schemas/Skill"
                    - type: object
                      properties:
                        metadata:
                          type: object
                          nullable: true
                          description: Additional skill metadata
                        help_desk_tier:
                          type: integer
                          nullable: true
                          example: 2
                        employee_count:
                          type: integer
                          example: 15
                        average_proficiency:
                          type: number
                          format: float
                          nullable: true
                          example: 3.4
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/categories":
    get:
      tags:
      - Skills & Certifications
      summary: Get skill categories
      description: |
        Get list of skill categories with counts

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                          example: technical_skills
                        label:
                          type: string
                          example: Technical Skills
                        skill_count:
                          type: integer
                          example: 25
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/trending":
    get:
      tags:
      - Skills & Certifications
      summary: Get trending skills
      description: |
        Get skills that have been added frequently by employees recently

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Trending skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  trending_skills:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          description:
                            type: string
                            nullable: true
                            example: Frontend and backend JavaScript programming
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: 'Catalog credential type. Clients map `license`
                              -> LIC badge, `certification` -> CERT badge, `skill`
                              -> no badge.

                              '
                            example: certification
                          requires_document_upload:
                            type: boolean
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                      - type: object
                        properties:
                          recent_additions:
                            type: integer
                            example: 8
                          total_employees:
                            type: integer
                            example: 15
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/recommended":
    get:
      tags:
      - Skills & Certifications
      summary: Get recommended skills
      description: |
        Get AI-recommended skills for the current user based on role, peers, and career progression

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        description: Maximum number of recommendations
        schema:
          type: integer
          minimum: 1
          maximum: 20
          example: 10
      responses:
        '200':
          description: Recommended skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  recommended_skills:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          description:
                            type: string
                            nullable: true
                            example: Frontend and backend JavaScript programming
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: 'Catalog credential type. Clients map `license`
                              -> LIC badge, `certification` -> CERT badge, `skill`
                              -> no badge.

                              '
                            example: certification
                          requires_document_upload:
                            type: boolean
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                      - type: object
                        properties:
                          recommendation_score:
                            type: number
                            format: float
                            example: 0.85
                          recommendation_reason:
                            type: string
                            example: 'Required for your current role: Software Engineer'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/skills/proficiency_levels":
    get:
      tags:
      - Skills & Certifications
      summary: Get proficiency levels
      description: |
        Get the fixed 1–5 proficiency scale with this business's configured level
        names and descriptions (falling back to platform defaults). Use this to
        populate a proficiency picker without hardcoding labels — the integer
        `level` is what you submit to the employee_skills endpoints.

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Proficiency levels retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  proficiency_levels:
                    type: array
                    items:
                      type: object
                      properties:
                        level:
                          type: integer
                          example: 3
                        name:
                          type: string
                          example: Intermediate
                        description:
                          type: string
                          example: Solid proficiency in the skill. Can work independently
                            on most tasks.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills":
    get:
      tags:
      - Skills & Certifications
      summary: List employee skills
      description: |
        Get the current user's skills with filtering and sorting options.

        Each item says whether skill detection added it (`detected`, `source`) and, on the
        caller's own holdings, `disputed` and `not_mine_available`.
        `meta.skill_detection_notice` is `{ shown, text }`: the phone list's explanation of
        automatic skill detection (text null and shown false when detection is off and the
        caller holds no detected skill).

        **Use Cases:**
        - View employee's skill profile
        - Filter skills by category or status
        - Check certification expiration dates

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        description: Filter by skill category
        schema:
          type: string
          example: technical_skills
      - name: proficiency_level
        in: query
        description: Filter by proficiency level
        schema:
          type: integer
          minimum: 1
          maximum: 5
          example: 3
      - name: verification_status
        in: query
        description: Filter by verification status. "ended" lists credentials a manager
          took off the record (kept as history); the other values leave them out.
        schema:
          type: string
          enum:
          - verified
          - unverified
          - pending
          - ended
          example: verified
      - name: certification_status
        in: query
        description: Filter by certification status
        schema:
          type: string
          enum:
          - certified
          - expiring
          - expired
          example: certified
      - name: expiring_days
        in: query
        description: Days ahead to check for expiring certifications (used with certification_status=expiring)
        schema:
          type: integer
          minimum: 1
          example: 30
      - name: active
        in: query
        description: Filter by active status
        schema:
          type: boolean
          example: true
      - name: sort_by
        in: query
        description: Sort by field
        schema:
          type: string
          enum:
          - skill_name
          - proficiency_level
          - certification_date
          - expiration_date
          - created_at
          example: skill_name
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          example: 1
      - name: per_page
        in: query
        description: Number of items per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          example: 25
      responses:
        '200':
          description: Employee skills retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          proficiency_level:
                            type: integer
                            minimum: 1
                            maximum: 5
                            example: 3
                          proficiency_text:
                            type: string
                            description: The level's name as the organization calls
                              it (Skills & Credentials › Settings › Proficiency Levels),
                              the platform's default names where it renamed none.
                              Sent on every item of the list, bulk and expiring endpoints
                              as well as show.
                            example: Intermediate
                          certification_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          expiring_soon:
                            type: boolean
                            description: True when the expiration date falls between
                              today and the organization's alert lead time for expiring
                              credentials (Skills & Credentials › Settings › Notifications,
                              "Credential expiring soon"). False with no expiration
                              date or once expired.
                            example: true
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          notes:
                            type: string
                            nullable: true
                            example: Completed advanced course
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          pending_upload:
                            type: boolean
                            description: 'A new certificate the employee uploaded
                              while this credential is still valid, waiting for a
                              manager''s review. The credential itself stays verified
                              and live meanwhile, so `verification_status=pending`
                              can return rows with `verified: true`.'
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                              requires_certification:
                                type: boolean
                                example: true
                              skill_kind:
                                type: string
                                enum:
                                - skill
                                - certification
                                - license
                                description: Raw catalog credential type. Drives the
                                  LIC/CERT badge in the web/mobile UI — clients map
                                  license -> "LIC", certification -> "CERT", skill
                                  -> no badge.
                                example: certification
                          detected:
                            type: boolean
                            description: Automatic skill detection added it (`source`
                              is "inferred"); the web and phone mark it Detected.
                            example: true
                          source:
                            type: string
                            description: How the holding was added, as stored ("inferred",
                              "manual", "self_reported", "external", …).
                            example: inferred
                          disputed:
                            type: boolean
                            description: Only on the caller's own holdings. They said
                              "Not mine" and a reviewer has not decided yet — the
                              holding is then inactive and unverified, which otherwise
                              reads like a rejection.
                            example: false
                          not_mine_available:
                            type: boolean
                            description: 'Only on the caller''s own holdings. POST
                              /employee_skills/{id}/not_mine would take it off now:
                              detected, active, its detection disputable, Skill Reviews
                              (Competency Assessment) open, and a token that may write
                              skills (`write:skills_certifications`, its own form
                              included).'
                            example: true
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Skills & Certifications
      summary: Add employee skill
      description: |
        Add a new skill to the current user's profile.

        **Use Cases:**
        - Employee adds a new skill they possess
        - Record certification information
        - Set proficiency level

        **Token scope:** `write:skills_certifications`; `write:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - skill_id
              - proficiency_level
              properties:
                skill_id:
                  type: integer
                  description: ID of the skill to add
                  example: 123
                proficiency_level:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Proficiency level (1=Basic, 2=Beginner, 3=Intermediate,
                    4=Advanced, 5=Expert)
                  example: 3
                certification_date:
                  type: string
                  format: date
                  description: Date when certification was obtained
                  example: '2024-01-15'
                expiration_date:
                  type: string
                  format: date
                  description: Date when certification expires
                  example: '2025-01-15'
                certification_number:
                  type: string
                  description: Certification number or ID
                  example: CERT-2024-001
                notes:
                  type: string
                  description: Additional notes about the skill
                  example: Completed advanced JavaScript course
      responses:
        '201':
          description: Employee skill created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        verification_status:
                          type: string
                          description: "`ended`: a manager took it off the record;
                            it is kept as history and held on no date from then on."
                          enum:
                          - verified
                          - pending
                          - unverified
                          - ended
                          example: verified
                        current_certification:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/CertificationPeriod"
                        certification_history:
                          type: array
                          description: 'Every award of this credential, newest first:
                            each course, learning path, renewal or upload that awarded
                            it is its own period.'
                          items:
                            "$ref": "#/components/schemas/CertificationPeriod"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/employee_skills/{id}":
    get:
      tags:
      - Skills & Certifications
      summary: Get employee skill details
      description: |
        Get detailed information about a specific employee skill

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Employee skill details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        verification_status:
                          type: string
                          description: "`ended`: a manager took it off the record;
                            it is kept as history and held on no date from then on."
                          enum:
                          - verified
                          - pending
                          - unverified
                          - ended
                          example: verified
                        current_certification:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/CertificationPeriod"
                        certification_history:
                          type: array
                          description: 'Every award of this credential, newest first:
                            each course, learning path, renewal or upload that awarded
                            it is its own period.'
                          items:
                            "$ref": "#/components/schemas/CertificationPeriod"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    put:
      tags:
      - Skills & Certifications
      summary: Update employee skill
      description: |
        Update an existing employee skill. Note that updating verified skills will reset verification status unless force_update=true is used.

        **Token scope:** `write:skills_certifications`; `write:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      - name: force_update
        in: query
        description: Force update even if skill is verified (resets verification)
        schema:
          type: boolean
          example: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                proficiency_level:
                  type: integer
                  minimum: 1
                  maximum: 5
                  description: Proficiency level
                  example: 4
                certification_date:
                  type: string
                  format: date
                  description: Date when certification was obtained
                  example: '2024-01-15'
                expiration_date:
                  type: string
                  format: date
                  description: Date when certification expires
                  example: '2025-01-15'
                certification_number:
                  type: string
                  description: Certification number or ID
                  example: CERT-2024-002
                notes:
                  type: string
                  description: Additional notes about the skill
                  example: Updated certification information
      responses:
        '200':
          description: Employee skill updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        verification_status:
                          type: string
                          description: "`ended`: a manager took it off the record;
                            it is kept as history and held on no date from then on."
                          enum:
                          - verified
                          - pending
                          - unverified
                          - ended
                          example: verified
                        current_certification:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/CertificationPeriod"
                        certification_history:
                          type: array
                          description: 'Every award of this credential, newest first:
                            each course, learning path, renewal or upload that awarded
                            it is its own period.'
                          items:
                            "$ref": "#/components/schemas/CertificationPeriod"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
    delete:
      tags:
      - Skills & Certifications
      summary: Remove employee skill
      description: |
        Remove a skill from the current user's profile.

        A holding with a validity period on record is ENDED, not deleted: it goes inactive, its
        current validity period ends (`expired`) the day before today, and its history stays on
        record. A holding with no validity period is deleted. Only holdings the caller may remove
        themselves (their own unverified claims and proposals) are accepted; one already ended
        answers 422 `employee_skill_ended`. Recording that an issuer revoked a credential is a
        manager's action, not this endpoint's.

        **Token scope:** `write:skills_certifications`; `write:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      responses:
        '204':
          description: Employee skill removed (ended with its history kept, or deleted
            when it had none)
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Already ended (`employee_skill_ended`), or the removal was
            refused (`employee_skill_not_removed`)
  "/employee_skills/bulk":
    get:
      tags:
      - Skills & Certifications
      summary: Get skills for multiple users
      description: |
        Fetch confirmed skills for a set of users, grouped by user.

        Your own id is always allowed. Anyone else's needs what Team Skills asks
        on the web — the Skills & Credentials app admin role, or a manager holding
        the `skills_certifications` view permission — and the person inside your reach
        (your reports and the people at locations you manage). Requested ids
        outside that reach, or not active members of the business, are omitted
        from `users` and listed in `meta.skipped_user_ids` rather than failing
        the request. A maximum of 100 user ids may be requested at once.

        **Token scope:** `read:skills_certifications`. Another person's skills need the wide
        scope itself: a token holding only `read:own_skills_certifications` may fetch the
        caller's own id and is refused 403 `insufficient_permissions` for anyone else.
      security:
      - BearerAuth: []
      parameters:
      - name: user_ids
        in: query
        required: true
        description: User ids to fetch skills for (comma-separated or repeated). Max
          100.
        style: form
        explode: false
        schema:
          type: array
          items:
            type: integer
          example:
          - 12
          - 15
          - 999
      responses:
        '200':
          description: Skills retrieved successfully for the authorized users
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                              example: 12
                            name:
                              type: string
                              example: Jane Doe
                        skills:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 456
                              skill_id:
                                type: integer
                                example: 123
                              proficiency_level:
                                type: integer
                                minimum: 1
                                maximum: 5
                                example: 3
                              proficiency_text:
                                type: string
                                description: The level's name as the organization
                                  calls it (Skills & Credentials › Settings › Proficiency
                                  Levels), the platform's default names where it renamed
                                  none. Sent on every item of the list, bulk and expiring
                                  endpoints as well as show.
                                example: Intermediate
                              certification_date:
                                type: string
                                format: date
                                nullable: true
                                example: '2024-01-15'
                              expiration_date:
                                type: string
                                format: date
                                nullable: true
                                example: '2025-01-15'
                              expiring_soon:
                                type: boolean
                                description: True when the expiration date falls between
                                  today and the organization's alert lead time for
                                  expiring credentials (Skills & Credentials › Settings
                                  › Notifications, "Credential expiring soon"). False
                                  with no expiration date or once expired.
                                example: true
                              certification_number:
                                type: string
                                nullable: true
                                example: CERT-2024-001
                              notes:
                                type: string
                                nullable: true
                                example: Completed advanced course
                              verified:
                                type: boolean
                                example: true
                              verified_at:
                                type: string
                                format: date-time
                                nullable: true
                                example: '2024-01-16T10:00:00Z'
                              pending_upload:
                                type: boolean
                                description: 'A new certificate the employee uploaded
                                  while this credential is still valid, waiting for
                                  a manager''s review. The credential itself stays
                                  verified and live meanwhile, so `verification_status=pending`
                                  can return rows with `verified: true`.'
                                example: false
                              active:
                                type: boolean
                                example: true
                              created_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                              updated_at:
                                type: string
                                format: date-time
                                example: '2024-01-15T10:00:00Z'
                              skill:
                                type: object
                                properties:
                                  id:
                                    type: integer
                                    example: 123
                                  name:
                                    type: string
                                    example: JavaScript Development
                                  category:
                                    type: string
                                    nullable: true
                                    example: technical_skills
                                  category_display:
                                    type: string
                                    nullable: true
                                    example: Technical Skills
                                  requires_certification:
                                    type: boolean
                                    example: true
                                  skill_kind:
                                    type: string
                                    enum:
                                    - skill
                                    - certification
                                    - license
                                    description: Raw catalog credential type. Drives
                                      the LIC/CERT badge in the web/mobile UI — clients
                                      map license -> "LIC", certification -> "CERT",
                                      skill -> no badge.
                                    example: certification
                              detected:
                                type: boolean
                                description: Automatic skill detection added it (`source`
                                  is "inferred"); the web and phone mark it Detected.
                                example: true
                              source:
                                type: string
                                description: How the holding was added, as stored
                                  ("inferred", "manual", "self_reported", "external",
                                  …).
                                example: inferred
                              disputed:
                                type: boolean
                                description: Only on the caller's own holdings. They
                                  said "Not mine" and a reviewer has not decided yet
                                  — the holding is then inactive and unverified, which
                                  otherwise reads like a rejection.
                                example: false
                              not_mine_available:
                                type: boolean
                                description: 'Only on the caller''s own holdings.
                                  POST /employee_skills/{id}/not_mine would take it
                                  off now: detected, active, its detection disputable,
                                  Skill Reviews (Competency Assessment) open, and
                                  a token that may write skills (`write:skills_certifications`,
                                  its own form included).'
                                example: true
                  meta:
                    type: object
                    properties:
                      requested_count:
                        type: integer
                        example: 3
                      returned_count:
                        type: integer
                        example: 2
                      skill_row_count:
                        type: integer
                        description: Skill rows returned across all requested users.
                        example: 120
                      skill_row_limit:
                        type: integer
                        description: Maximum skill rows this endpoint returns in one
                          response.
                        example: 2500
                      truncated:
                        type: boolean
                        description: True when `skill_row_limit` was hit and later
                          rows were dropped. Which rows survive follows the request's
                          sort order.
                        example: false
                      skipped_user_ids:
                        type: array
                        items:
                          type: integer
                        example:
                        - 999
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills/expiring":
    get:
      tags:
      - Skills & Certifications
      summary: Get expiring skills
      description: |
        Get employee skills with certifications that are expiring soon

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: days
        in: query
        description: Number of days ahead to check for expiring certifications
        schema:
          type: integer
          minimum: 1
          example: 90
      responses:
        '200':
          description: Expiring skills retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  expiring_skills:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          proficiency_level:
                            type: integer
                            minimum: 1
                            maximum: 5
                            example: 3
                          proficiency_text:
                            type: string
                            description: The level's name as the organization calls
                              it (Skills & Credentials › Settings › Proficiency Levels),
                              the platform's default names where it renamed none.
                              Sent on every item of the list, bulk and expiring endpoints
                              as well as show.
                            example: Intermediate
                          certification_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          expiring_soon:
                            type: boolean
                            description: True when the expiration date falls between
                              today and the organization's alert lead time for expiring
                              credentials (Skills & Credentials › Settings › Notifications,
                              "Credential expiring soon"). False with no expiration
                              date or once expired.
                            example: true
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          notes:
                            type: string
                            nullable: true
                            example: Completed advanced course
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          pending_upload:
                            type: boolean
                            description: 'A new certificate the employee uploaded
                              while this credential is still valid, waiting for a
                              manager''s review. The credential itself stays verified
                              and live meanwhile, so `verification_status=pending`
                              can return rows with `verified: true`.'
                            example: false
                          active:
                            type: boolean
                            example: true
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                              requires_certification:
                                type: boolean
                                example: true
                              skill_kind:
                                type: string
                                enum:
                                - skill
                                - certification
                                - license
                                description: Raw catalog credential type. Drives the
                                  LIC/CERT badge in the web/mobile UI — clients map
                                  license -> "LIC", certification -> "CERT", skill
                                  -> no badge.
                                example: certification
                          detected:
                            type: boolean
                            description: Automatic skill detection added it (`source`
                              is "inferred"); the web and phone mark it Detected.
                            example: true
                          source:
                            type: string
                            description: How the holding was added, as stored ("inferred",
                              "manual", "self_reported", "external", …).
                            example: inferred
                          disputed:
                            type: boolean
                            description: Only on the caller's own holdings. They said
                              "Not mine" and a reviewer has not decided yet — the
                              holding is then inactive and unverified, which otherwise
                              reads like a rejection.
                            example: false
                          not_mine_available:
                            type: boolean
                            description: 'Only on the caller''s own holdings. POST
                              /employee_skills/{id}/not_mine would take it off now:
                              detected, active, its detection disputable, Skill Reviews
                              (Competency Assessment) open, and a token that may write
                              skills (`write:skills_certifications`, its own form
                              included).'
                            example: true
                      - type: object
                        properties:
                          days_until_expiration:
                            type: integer
                            example: 15
                          expiration_urgency:
                            type: string
                            enum:
                            - expired
                            - critical
                            - warning
                            - notice
                            - normal
                            example: warning
                  total_count:
                    type: integer
                    example: 3
                  days_ahead:
                    type: integer
                    example: 90
                  truncated:
                    type: boolean
                    description: True when the result set hit `row_limit` and was
                      cut. When true, `total_count` counts the RETURNED rows, not
                      the whole matching set, and any summary/aggregate in this response
                      is likewise page-local.
                    example: false
                  row_limit:
                    type: integer
                    description: Maximum rows this endpoint will return in one response.
                    example: 500
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills/categories":
    get:
      tags:
      - Skills & Certifications
      summary: Get employee skills by category
      description: |
        Get employee's skills grouped by category with statistics

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Skills categories retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        category:
                          type: string
                          example: technical_skills
                        category_display:
                          type: string
                          example: Technical Skills
                        skill_count:
                          type: integer
                          example: 5
                        average_proficiency:
                          type: number
                          format: float
                          example: 3.4
                        certified_count:
                          type: integer
                          example: 2
                        expiring_soon_count:
                          type: integer
                          example: 1
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/employee_skills/{id}/request_verification":
    post:
      tags:
      - Skills & Certifications
      summary: Request skill verification
      description: |
        Request verification for an employee skill from manager

        **Token scope:** `write:skills_certifications`; `write:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill ID
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Verification request sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Verification request sent to your manager
                  employee_skill:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 456
                      skill_id:
                        type: integer
                        example: 123
                      proficiency_level:
                        type: integer
                        minimum: 1
                        maximum: 5
                        example: 3
                      proficiency_text:
                        type: string
                        description: The level's name as the organization calls it
                          (Skills & Credentials › Settings › Proficiency Levels),
                          the platform's default names where it renamed none. Sent
                          on every item of the list, bulk and expiring endpoints as
                          well as show.
                        example: Intermediate
                      certification_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2024-01-15'
                      expiration_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2025-01-15'
                      expiring_soon:
                        type: boolean
                        description: True when the expiration date falls between today
                          and the organization's alert lead time for expiring credentials
                          (Skills & Credentials › Settings › Notifications, "Credential
                          expiring soon"). False with no expiration date or once expired.
                        example: true
                      certification_number:
                        type: string
                        nullable: true
                        example: CERT-2024-001
                      notes:
                        type: string
                        nullable: true
                        example: Completed advanced course
                      verified:
                        type: boolean
                        example: true
                      verified_at:
                        type: string
                        format: date-time
                        nullable: true
                        example: '2024-01-16T10:00:00Z'
                      pending_upload:
                        type: boolean
                        description: 'A new certificate the employee uploaded while
                          this credential is still valid, waiting for a manager''s
                          review. The credential itself stays verified and live meanwhile,
                          so `verification_status=pending` can return rows with `verified:
                          true`.'
                        example: false
                      active:
                        type: boolean
                        example: true
                      created_at:
                        type: string
                        format: date-time
                        example: '2024-01-15T10:00:00Z'
                      updated_at:
                        type: string
                        format: date-time
                        example: '2024-01-15T10:00:00Z'
                      skill:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 123
                          name:
                            type: string
                            example: JavaScript Development
                          category:
                            type: string
                            nullable: true
                            example: technical_skills
                          category_display:
                            type: string
                            nullable: true
                            example: Technical Skills
                          requires_certification:
                            type: boolean
                            example: true
                          skill_kind:
                            type: string
                            enum:
                            - skill
                            - certification
                            - license
                            description: Raw catalog credential type. Drives the LIC/CERT
                              badge in the web/mobile UI — clients map license ->
                              "LIC", certification -> "CERT", skill -> no badge.
                            example: certification
                      detected:
                        type: boolean
                        description: Automatic skill detection added it (`source`
                          is "inferred"); the web and phone mark it Detected.
                        example: true
                      source:
                        type: string
                        description: How the holding was added, as stored ("inferred",
                          "manual", "self_reported", "external", …).
                        example: inferred
                      disputed:
                        type: boolean
                        description: Only on the caller's own holdings. They said
                          "Not mine" and a reviewer has not decided yet — the holding
                          is then inactive and unverified, which otherwise reads like
                          a rejection.
                        example: false
                      not_mine_available:
                        type: boolean
                        description: 'Only on the caller''s own holdings. POST /employee_skills/{id}/not_mine
                          would take it off now: detected, active, its detection disputable,
                          Skill Reviews (Competency Assessment) open, and a token
                          that may write skills (`write:skills_certifications`, its
                          own form included).'
                        example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/employee_skills/{id}/not_mine":
    post:
      tags:
      - Skills & Certifications
      summary: Not mine — take a detected skill off your profile
      description: |
        The phone My Skills card's "Not mine" on a skill automatic skill detection added
        (`detected: true`, `not_mine_available: true`). The holding becomes inactive and
        unverified, its detection is marked disputed, and a reviewer is asked to remove it or
        put it back. Only the caller's own holding (another person's id is 404). Refused (422)
        with `detection_paused` while Competency Assessment is off, `not_detected` for a skill
        detection did not add (or already disputed), `already_disputed` / `already_decided`
        when the detection changed meanwhile.

        **Token scope:** `write:skills_certifications` (its `own_` form also satisfies it).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Employee skill (holding) ID
        schema:
          type: integer
      responses:
        '200':
          description: Taken off — the holding, detailed
          content:
            application/json:
              schema:
                type: object
                properties:
                  employee_skill:
                    allOf:
                    - "$ref": "#/components/schemas/EmployeeSkill"
                    - type: object
                      properties:
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        verification_status:
                          type: string
                          description: "`ended`: a manager took it off the record;
                            it is kept as history and held on no date from then on."
                          enum:
                          - verified
                          - pending
                          - unverified
                          - ended
                          example: verified
                        current_certification:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/CertificationPeriod"
                        certification_history:
                          type: array
                          description: 'Every award of this credential, newest first:
                            each course, learning path, renewal or upload that awarded
                            it is its own period.'
                          items:
                            "$ref": "#/components/schemas/CertificationPeriod"
        '404':
          description: Not the caller's holding (employee_skill_not_found)
        '422':
          description: Refused — error.code detection_paused | not_detected | already_disputed
            | already_decided, with the reason
  "/certifications":
    get:
      tags:
      - Skills & Certifications
      summary: List employee certifications
      description: |
        Get the current user's certifications with filtering and sorting options.

        **Use Cases:**
        - View all employee certifications
        - Check certification status and expiration dates
        - Filter by category or verification status

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: category
        in: query
        description: Filter by skill category
        schema:
          type: string
          example: technical_skills
      - name: status
        in: query
        description: Filter by certification status. `ended` lists credentials a manager
          took off the record; the other values leave those out.
        schema:
          type: string
          enum:
          - active
          - expired
          - expiring
          - ended
          example: active
      - name: expiring_days
        in: query
        description: Days ahead to check for expiring certifications (used with status=expiring)
        schema:
          type: integer
          minimum: 1
          example: 30
      - name: verification_status
        in: query
        description: Filter by verification status
        schema:
          type: string
          enum:
          - verified
          - unverified
          example: verified
      - name: sort_by
        in: query
        description: Sort by field
        schema:
          type: string
          enum:
          - skill_name
          - certification_date
          - expiration_date
          - created_at
          example: expiration_date
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          example: 1
      - name: per_page
        in: query
        description: Number of items per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          example: 25
      responses:
        '200':
          description: Certifications retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          certification_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/{id}":
    get:
      tags:
      - Skills & Certifications
      summary: Get certification details
      description: |
        Get detailed information about a specific certification

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Certification ID (employee skill ID)
        schema:
          type: integer
          example: 456
      responses:
        '200':
          description: Certification details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  certification:
                    allOf:
                    - "$ref": "#/components/schemas/Certification"
                    - type: object
                      properties:
                        notes:
                          type: string
                          nullable: true
                          example: Completed advanced course
                        proficiency_level:
                          type: integer
                          minimum: 1
                          maximum: 5
                          example: 3
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        status:
                          type: string
                          description: "`ended`: a manager took it off the record;
                            kept as history."
                          enum:
                          - active
                          - expiring_soon
                          - expiring_notice
                          - expired
                          - ended
                          example: active
                        document_attached:
                          type: boolean
                          example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/expiring":
    get:
      tags:
      - Skills & Certifications
      summary: Get expiring certifications
      description: |
        Get certifications that are expiring soon with urgency levels

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: days
        in: query
        description: Number of days ahead to check for expiring certifications
        schema:
          type: integer
          minimum: 1
          example: 90
      responses:
        '200':
          description: Expiring certifications retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  expiring_certifications:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          certification_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                      - type: object
                        properties:
                          days_until_expiration:
                            type: integer
                            example: 15
                          expiration_urgency:
                            type: string
                            enum:
                            - expired
                            - critical
                            - warning
                            - notice
                            - normal
                            example: warning
                          renewal_required:
                            type: boolean
                            example: true
                  total_count:
                    type: integer
                    example: 3
                  days_ahead:
                    type: integer
                    example: 90
                  truncated:
                    type: boolean
                    description: True when the result set hit `row_limit` and was
                      cut. When true, `total_count` counts the RETURNED rows, not
                      the whole matching set, and any summary/aggregate in this response
                      is likewise page-local.
                    example: false
                  row_limit:
                    type: integer
                    description: Maximum rows this endpoint will return in one response.
                    example: 500
                  summary:
                    type: object
                    properties:
                      critical:
                        type: integer
                        example: 1
                      warning:
                        type: integer
                        example: 2
                      notice:
                        type: integer
                        example: 0
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/expired":
    get:
      tags:
      - Skills & Certifications
      summary: Get expired certifications
      description: |
        Get certifications that have already expired

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Expired certifications retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  expired_certifications:
                    type: array
                    items:
                      allOf:
                      - type: object
                        properties:
                          id:
                            type: integer
                            example: 456
                          skill_id:
                            type: integer
                            example: 123
                          certification_date:
                            type: string
                            format: date
                            example: '2024-01-15'
                          expiration_date:
                            type: string
                            format: date
                            nullable: true
                            example: '2025-01-15'
                          certification_number:
                            type: string
                            nullable: true
                            example: CERT-2024-001
                          verified:
                            type: boolean
                            example: true
                          verified_at:
                            type: string
                            format: date-time
                            nullable: true
                            example: '2024-01-16T10:00:00Z'
                          created_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          updated_at:
                            type: string
                            format: date-time
                            example: '2024-01-15T10:00:00Z'
                          skill:
                            type: object
                            properties:
                              id:
                                type: integer
                                example: 123
                              name:
                                type: string
                                example: JavaScript Development
                              category:
                                type: string
                                nullable: true
                                example: technical_skills
                              category_display:
                                type: string
                                nullable: true
                                example: Technical Skills
                      - type: object
                        properties:
                          days_expired:
                            type: integer
                            example: 30
                          renewal_required:
                            type: boolean
                            example: true
                  total_count:
                    type: integer
                    example: 2
                  truncated:
                    type: boolean
                    description: True when the result set hit `row_limit` and was
                      cut. When true, `total_count` counts the RETURNED rows, not
                      the whole matching set.
                    example: false
                  row_limit:
                    type: integer
                    description: Maximum rows this endpoint will return in one response.
                    example: 500
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/summary":
    get:
      tags:
      - Skills & Certifications
      summary: Get certification summary
      description: |
        Get summary statistics of employee's certifications

        **Token scope:** `read:skills_certifications`; `read:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Certification summary retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  certification_summary:
                    type: object
                    properties:
                      total_certifications:
                        type: integer
                        example: 8
                      active_certifications:
                        type: integer
                        example: 6
                      expired_certifications:
                        type: integer
                        example: 2
                      expiring_soon:
                        type: integer
                        description: Certifications expiring within the organization's
                          expiry-alert lead time (Settings › Notifications; 30 days
                          unless changed).
                        example: 1
                      verified_certifications:
                        type: integer
                        example: 7
                      by_category:
                        type: object
                        additionalProperties:
                          type: object
                          properties:
                            total:
                              type: integer
                            active:
                              type: integer
                            expiring_soon:
                              type: integer
                              description: As the top-level expiring_soon, for this
                                category.
                      recent_activity:
                        type: array
                        items:
                          type: object
                          properties:
                            skill_name:
                              type: string
                              example: JavaScript Development
                            certification_date:
                              type: string
                              format: date
                              example: '2024-01-15'
                            created_at:
                              type: string
                              format: date-time
                              example: '2024-01-15T10:00:00Z'
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/certifications/{id}/renew":
    post:
      tags:
      - Skills & Certifications
      summary: Renew certification
      description: |
        Renew an existing certification with new dates and information.

        **Use Cases:**
        - Employee renews an expiring certification
        - Update certification with new expiration date
        - Record new certification number

        **Token scope:** `write:skills_certifications`; `write:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Certification ID (employee skill ID)
        schema:
          type: integer
          example: 456
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                new_certification_date:
                  type: string
                  format: date
                  description: New certification date
                  example: '2024-02-01'
                new_expiration_date:
                  type: string
                  format: date
                  description: New expiration date
                  example: '2025-02-01'
                new_certification_number:
                  type: string
                  description: New certification number
                  example: CERT-2024-003
                notes:
                  type: string
                  description: Notes about the renewal
                  example: Renewed with updated curriculum
      responses:
        '201':
          description: Certification renewed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Certification renewed successfully
                  certification:
                    allOf:
                    - "$ref": "#/components/schemas/Certification"
                    - type: object
                      properties:
                        notes:
                          type: string
                          nullable: true
                          example: Completed advanced course
                        proficiency_level:
                          type: integer
                          minimum: 1
                          maximum: 5
                          example: 3
                        verifier:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                              example: 789
                            name:
                              type: string
                              example: John Manager
                        days_until_expiration:
                          type: integer
                          nullable: true
                          example: 45
                        expired:
                          type: boolean
                          example: false
                        expiring_soon:
                          type: boolean
                          example: true
                        status:
                          type: string
                          description: "`ended`: a manager took it off the record;
                            kept as history."
                          enum:
                          - active
                          - expiring_soon
                          - expiring_notice
                          - expired
                          - ended
                          example: active
                        document_attached:
                          type: boolean
                          example: true
                  renewal_id:
                    type: string
                    example: renewal-uuid-123
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/certifications/upload_document":
    post:
      tags:
      - Skills & Certifications
      summary: Upload certification document
      description: |
        Upload a document file for a certification

        **Token scope:** `write:skills_certifications`; `write:own_skills_certifications`, what a
        token made by hand carries, also satisfies it and reaches the caller's own records.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              - employee_skill_id
              properties:
                file:
                  type: string
                  format: binary
                  description: Certification document file
                employee_skill_id:
                  type: integer
                  description: Employee skill ID for the certification
                  example: 456
      responses:
        '200':
          description: Document uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Certification document uploaded successfully
                  document:
                    type: object
                    properties:
                      id:
                        type: integer
                        example: 789
                      filename:
                        type: string
                        example: certificate.pdf
                      content_type:
                        type: string
                        example: application/pdf
                      byte_size:
                        type: integer
                        example: 1024000
                      url:
                        type: string
                        example: "/rails/active_storage/blobs/xyz/certificate.pdf"
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Not found
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ValidationErrors"
  "/safety_hub/dashboard":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Dashboard KPI tiles
      description: |
        Returns the caller's home-screen KPI tiles in display order. Each tile
        is self-describing — it carries its own label, count, and Bootstrap
        text color class so native-mobile clients render the same palette as
        the web Safety Hub dashboard. The list is keyed and ordered, so a
        client that iterates it renders either view correctly.

        Which tiles depends on who is asking:

          * **Safety Hub managers** (manager_or_above, or a Safety Hub app
            admin) whose token carries `read:safety_hub` get **five TEAM
            tiles**: the team's numbers, with incidents and expiring
            certifications limited to the sites they can see.
          * **Everyone else** — members, and a manager whose token carries only
            `read:own_safety_hub` — gets **four PERSONAL tiles**, labelled
            "My …": their own certifications, the observations they recorded,
            the incidents they reported, and the talks they facilitate or are
            expected at. `days_without_injury` has no personal form and is
            omitted rather than reported misleadingly.

        Tiles, in order (team labels shown; personal labels prefix "My "):
          1. `days_without_injury` (team only) — whole days since the most
             recent injury-type incident. `count` is **null** when the business
             has no injury on record — the best state, not zero days — and the
             label then reads "No Injuries on Record" instead of "Days Without
             Injury". Color: `text-success`.
          2. `certifications_expiring_soon` — certifications expiring within
             the business-configured reminder window (default 30 days),
             aggregated across EmployeeSkill and LmsTrainingRecord. Color:
             `text-warning`.
          3. `observations_this_month` — safety observations recorded since
             the start of the current month. Color: `text-primary`.
          4. `incidents_this_month` — non-cancelled incidents occurring since
             the start of the current month. Color: `text-danger`.
          5. `upcoming_toolbox_talks` — toolbox talks scheduled in the future.
             Color: `text-info`.
      responses:
        '200':
          description: Dashboard KPI tiles
          content:
            application/json:
              schema:
                type: object
                required:
                - tiles
                properties:
                  tiles:
                    type: array
                    description: KPI tiles in display order — five team tiles for
                      a Safety Hub manager whose token carries read:safety_hub, otherwise
                      four personal tiles (no days_without_injury).
                    minItems: 4
                    maxItems: 5
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - count
                      - color_class
                      properties:
                        key:
                          type: string
                          enum:
                          - days_without_injury
                          - certifications_expiring_soon
                          - observations_this_month
                          - incidents_this_month
                          - upcoming_toolbox_talks
                          description: Stable machine key identifying the tile.
                        label:
                          type: string
                          description: Human-readable title shown on the tile. Personal
                            tiles are prefixed "My "; days_without_injury reads "No
                            Injuries on Record" when its count is null.
                          example: Days Without Injury
                        count:
                          type: integer
                          nullable: true
                          minimum: 0
                          description: Numeric value for the tile. Null only for days_without_injury
                            when no injury is on record (render the label, not a number).
                          example: 42
                        color_class:
                          type: string
                          enum:
                          - text-success
                          - text-warning
                          - text-primary
                          - text-danger
                          - text-info
                          description: Bootstrap text color class to apply to the
                            tile value.
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: Forbidden — Safety Hub not enabled for the business
          or user lacks access:
  "/safety_hub/config":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Create-form picker vocabularies
      description: |
        The option lists a native client needs to render the Safety Hub create
        forms — the API twin of the selects the desktop `new` views build inline.

        Each block is present ONLY when its module is enabled for the business,
        mirroring every other endpoint here (a tenant with Permits switched off
        gets no `permit_types` / `contractors`). Keys are stable, so a client
        renders whatever blocks are present:

          * `incident_types`, `incident_severities` — the two required selects on
            the report-incident form. Sourced from the Incident model's own enums,
            so they can never drift from what `POST /safety_hub/incidents` accepts.
          * `observation_categories` — the required Category select on the
            observation form. This is the TENANT'S configured list (Safety Hub
            app setting), so it varies per business; values are display-ready
            strings (`label` == `value`).
          * `permit_types` — the Permit Type select, from the WorkPermit type
            catalog; each carries a human `label` and a Font Awesome `icon`.
          * `contractors` — the tenant's active vendor roster for the
            "Contractor (if external)" select; the permit form writes the chosen
            `id` to `vendor_id`.

        Read-only. Requires `read:safety_hub`. No pagination and no role branch —
        these are business-wide form vocabularies, identical for every member.
      responses:
        '200':
          description: The enabled modules' picker vocabularies.
          content:
            application/json:
              schema:
                type: object
                required:
                - config
                properties:
                  config:
                    type: object
                    description: |
                      Only the enabled modules' blocks are present. Absent keys
                      mean that module is off for the business.
                    properties:
                      incident_types:
                        type: array
                        description: Incident Type select options (Incidents module).
                        items:
                          type: object
                          required:
                          - value
                          - label
                          properties:
                            value:
                              type: string
                              example: near_miss
                            label:
                              type: string
                              example: Near Miss
                      incident_severities:
                        type: array
                        description: Severity select options (Incidents module).
                        items:
                          type: object
                          required:
                          - value
                          - label
                          properties:
                            value:
                              type: string
                              example: high
                            label:
                              type: string
                              example: High
                      observation_categories:
                        type: array
                        description: |
                          Observation Category select options (Observations
                          module). The tenant's configured list — label == value.
                        items:
                          type: object
                          required:
                          - value
                          - label
                          properties:
                            value:
                              type: string
                              example: PPE
                            label:
                              type: string
                              example: PPE
                      permit_types:
                        type: array
                        description: Permit Type select options (Permits module).
                        items:
                          type: object
                          required:
                          - value
                          - label
                          - icon
                          properties:
                            value:
                              type: string
                              example: hot_work
                            label:
                              type: string
                              example: Hot work
                            icon:
                              type: string
                              example: fa-fire
                      contractors:
                        type: array
                        description: |
                          Active vendors for the "Contractor (if external)"
                          select (Permits module). The permit form writes the
                          chosen `id` to `vendor_id`.
                        items:
                          type: object
                          required:
                          - id
                          - name
                          properties:
                            id:
                              type: integer
                              example: 42
                            name:
                              type: string
                              example: Acme Scaffolding Ltd
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: Forbidden — Safety Hub not enabled for the business
          or user lacks access:
  "/safety_hub/incidents":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List incidents
      description: |
        Personal feed by DEFAULT: incidents the caller reported or is assigned
        to investigate, newest first. Pass `team=true` for the business-wide
        feed; that requires Safety Hub manager access and is additionally
        site-scoped, so a manager restricted to particular locations sees only
        incidents at those sites.

        Anonymous incidents are included; filter on the client if needed.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` for the business-wide feed (manager-gated and
          site-scoped). Omit or set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: Filter by lifecycle status
        schema:
          type: string
          enum:
          - reported
          - investigating
          - investigation_completed
          - closed
          - cancelled
      - name: severity
        in: query
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: type
        in: query
        description: Filter by incident_type
        schema:
          type: string
          enum:
          - injury
          - near_miss
          - property_damage
          - environmental
          - security
          - vehicle_accident
          - equipment_failure
      - name: location_id
        in: query
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of incidents
          content:
            application/json:
              schema:
                type: object
                properties:
                  incidents:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Incident"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Report a new incident
      description: |
        Report a new incident — the native mirror of the mockup's "My
        Submission" → camera → "Continue without photo" → incident form flow,
        and the API twin of the desktop
        `Apps::SafetyHub::IncidentsController#create` / mobile `#create_incident`.

        **Any member may file a report** — the caller is always recorded as the
        reporter (and `created_by`) and the status starts `reported`. Only
        `title`, `description`, `incident_type`, `severity` and `occurred_at` are
        required; everything else is optional, so a phone can post the bare form
        the mockup shows and add photos, people and witnesses later.

        **Photos** are optional and attach through `MediaItem` (multipart
        `photos[]`), exactly as both web surfaces do, so the AI-vision /
        EXIF-strip / transcription pipelines fire and the detail screen's
        timeline picks them up. When the tenant has `require_photos_for_injuries`
        on, an **injury** report with no photo is refused (422).

        `location_id` / `alert_id` that don't belong to the caller's business are
        dropped (never 422'd). Manager notifications and investigator
        auto-assignment are the model's job and fire here identically to every
        other create path.

        Post-commit steps (photos, people, witnesses, WCB reportability) that
        fail **after** the incident row is written are collected into `warnings`
        rather than turning a filed report into an error — the report itself is
        still saved and returned.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        (the by-hand employee grant). Gated on the tenant's `incidents_enabled`
        module toggle.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SafetyHubIncidentCreateRequest"
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/SafetyHubIncidentCreateRequest"
              - type: object
                properties:
                  photos:
                    type: array
                    description: Optional scene photos/videos, attached as MediaItems.
                    items:
                      type: string
                      format: binary
      responses:
        '201':
          description: Incident reported
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
                  warnings:
                    type: array
                    description: |
                      Post-commit issues that did not block the report (a photo
                      that failed to attach, a person/witness row that failed
                      validation, WCB reportability that could not be
                      determined). Empty on a clean save.
                    items:
                      type: string
        '400':
          description: Bad request — the incident payload is missing entirely
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — the token lacks the write scope, or Safety Hub
            / the Incidents module is disabled
        '422':
          description: Unprocessable — validation failed, or an injury report is missing
            a required photo
  "/safety_hub/incidents/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one incident
      description: |
        The reporter, the assigned investigator and a manager of the incident's
        site get `incident` + `permissions` (a reporter who is the injured person
        also gets their own row as `your_injury`). Anyone else named as an
        injured person on the incident gets ONLY `own_injury_record` — when,
        where and what happened, who is handling it, and their own injury row;
        never the reporter, witnesses, other people or the investigation. Any
        other caller gets 403.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Incident detail, or the caller's own injury record
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
                  your_injury:
                    nullable: true
                    type: object
                    properties:
                      injury:
                        type: string
                      body_parts_affected:
                        type: string
                        nullable: true
                      medical_attention:
                        type: boolean
                      hospitalized:
                        type: boolean
                      days_away_from_work:
                        type: integer
                      days_restricted_work:
                        type: integer
                      return_to_work_date:
                        type: string
                        format: date
                        nullable: true
                      return_to_full_duty:
                        type: boolean
                        nullable: true
                      work_restrictions:
                        type: string
                        nullable: true
                      osha_case_number:
                        type: string
                        nullable: true
                  own_injury_record:
                    type: object
                    description: Returned INSTEAD of incident/permissions to a caller
                      who may see only their own injury record.
                    properties:
                      id:
                        type: integer
                      reference:
                        type: string
                      status:
                        type: string
                      occurred_at:
                        type: string
                        format: date-time
                        nullable: true
                      location:
                        type: object
                        nullable: true
                        properties:
                          name:
                            type: string
                      description:
                        type: string
                        nullable: true
                      investigator:
                        type: object
                        nullable: true
                        properties:
                          name:
                            type: string
                      url:
                        type: string
                      your_injury:
                        type: object
                        properties:
                          injury:
                            type: string
                          body_parts_affected:
                            type: string
                            nullable: true
                          medical_attention:
                            type: boolean
                          hospitalized:
                            type: boolean
                          days_away_from_work:
                            type: integer
                          days_restricted_work:
                            type: integer
                          return_to_work_date:
                            type: string
                            format: date
                            nullable: true
                          return_to_full_duty:
                            type: boolean
                            nullable: true
                          work_restrictions:
                            type: string
                            nullable: true
                          osha_case_number:
                            type: string
                            nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Incident not found
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Edit an incident
      description: |
        Correct ONE incident from the native detail "⋯ → Edit incident" flow,
        which reopens the report form prefilled with the record's own title,
        description, immediate actions, type, severity and location. The API twin
        of the desktop `Apps::SafetyHub::IncidentsController#update` (mobile-web
        has no edit route, so the desktop controller is the authority).

        Authority mirrors the desktop `#authorize_incident_edit` and the detail
        endpoint's `permissions.can_edit`: the incident's **reporter**, OR a
        **safety-hub manager** whose accessible sites include the incident's site.
        A **closed / cancelled** record is frozen (audit-trail integrity) and the
        OSHA/WCB matrix narrows what a late-stage record accepts — both return 422.

        The permitted fields are the SAME set as create; **`status` is not
        editable here** (lifecycle moves through the dedicated workflow actions),
        **`anonymous` is fixed once the incident is reported** (a different value
        is a 422 — the desktop edit form no longer offers it either),
        and photos/people/witnesses are attached **additively** — a
        `require_photos_for_injuries` policy is not re-checked on edit, matching
        the web. A cross-tenant `location_id` / `alert_id` is dropped rather than
        rejected. Audit rows (`IncidentUpdate`) and platform/WCB side effects are
        the model's job and fire automatically.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        when editing your OWN report. Editing another user's incident via manager
        privilege requires the wide `write:safety_hub`. Gated on the tenant's
        `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - incident
              properties:
                incident:
                  type: object
                  description: |
                    Any subset of the editable fields. Fields omitted are left
                    unchanged.
                  properties:
                    title:
                      type: string
                      minLength: 3
                      maxLength: 255
                    description:
                      type: string
                      minLength: 10
                      maxLength: 5000
                    occurred_at:
                      type: string
                      format: date-time
                    location_id:
                      type: integer
                      description: A site in the caller's business; a cross-tenant
                        id is dropped.
                    incident_type:
                      type: string
                      enum:
                      - injury
                      - near_miss
                      - property_damage
                      - environmental
                      - security
                      - vehicle_accident
                      - equipment_failure
                    severity:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                    immediate_actions:
                      type: string
                    anonymous:
                      type: boolean
                      description: |
                        Fixed when the incident is reported. Omit it, or send the
                        incident's current value; a different value is refused with
                        422 and nothing is saved.
                    confidential:
                      type: boolean
                    days_away_from_work:
                      type: integer
                    days_restricted_work:
                      type: integer
                    fatality:
                      type: boolean
                    wcb_province:
                      type: string
                    alert_id:
                      type: integer
                link_people:
                  type: object
                  description: |
                    "Their account" for people already on the incident: a map of
                    person row id to the account id in this business ("" clears
                    it). A newly linked injured person is told and can open their
                    own injury record; an id that is no account here comes back
                    in `warnings`.
                  additionalProperties:
                    type: string
      responses:
        '200':
          description: Incident updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Non-fatal post-save issues (e.g. a photo that failed
                      to attach); the edit still succeeded.
        '400':
          description: Bad request — the incident payload is missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the reporter or a site manager, an own-scoped
            token editing another user's incident, or Safety Hub / the Incidents module
            disabled
        '404':
          description: Incident not found
        '422':
          description: Unprocessable — validation failed (including an attempt to
            change `anonymous`), or the incident is closed/cancelled or otherwise
            frozen by the OSHA/WCB status matrix
  "/safety_hub/incidents/{id}/investigators":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List an incident's candidate investigators
      description: |
        The candidate investigator list for ONE incident — the native mirror of
        the desktop show view's "Reassign Investigator" picker
        (`Apps::SafetyHub::IncidentsController#load_form_data`'s
        `@potential_investigators`), so a phone can render the same select before
        POSTing an assignment.

        The candidates are the SHARED `#potential_investigators` — active
        **super_admins / admins / managers** in the business, ordered by first
        name — the SAME relation the desktop picker and its server-side resolve
        use, so no surface can offer someone the others would not. One query for
        the whole list; each entry carries `is_current` (whether they are the
        incident's current assignee).

        **Manager-only, within accessible sites** — exactly the personas the web
        offers the control to (the Investigation Workflow panel is gated on
        `safety_hub_manager? && incident_within_accessible_sites?`, and the
        reassignment write it feeds carries `authorize_safety_hub_manager!` +
        `authorize_incident_site_access`). A plain member — even the reporter, who
        can read the incident itself — is refused. `reassignable` echoes the web's
        status gate (the picker renders only while the incident is `reported` or
        `investigating`); the list is still returned for a closed incident so a
        manager can see who would be eligible.

        Requires the **read** scope `read:safety_hub` (the manager roster is
        management data, so an own-scoped `read:own_safety_hub` token is refused).
        Gated on the tenant's `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The candidate investigator list
          content:
            application/json:
              schema:
                type: object
                properties:
                  investigators:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        is_current:
                          type: boolean
                          description: True for the incident's current assigned investigator.
                  current_investigator:
                    type: object
                    nullable: true
                    description: The currently assigned investigator, or null when
                      unassigned.
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  reassignable:
                    type: boolean
                    description: True while the incident is reported or investigating
                      (a closed/cancelled incident's investigator is frozen).
                  incident:
                    type: object
                    properties:
                      id:
                        type: integer
                      reference:
                        type: string
                        description: INC-<id>
                      status:
                        type: string
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the incident is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Incidents module disabled
        '404':
          description: Incident not found (or not in the caller's business)
  "/safety_hub/incidents/{id}/investigator":
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Assign or reassign an incident's investigator
      description: |
        Assign or reassign ONE incident's investigator — the native mirror of the
        desktop show view's manager-only "Reassign Investigator" picker
        (`Apps::SafetyHub::IncidentsController#assign_investigator`) and the write
        the candidate list (`GET .../investigators`) feeds.

        The target must be an active **super_admin / admin / manager** of the
        business — the SHARED `#potential_investigators` relation the picker, its
        desktop resolve and the candidate list all use — so this endpoint can never
        accept someone the select never offered. A missing `investigator_id` is a
        400; an id outside that eligible set is a 422.

        **Manager-only, within accessible sites** — exactly the personas the web
        offers the control to (`authorize_safety_hub_manager!` +
        `authorize_incident_site_access`). A closed / cancelled incident's
        investigator is frozen (the web renders the picker only while the incident
        is `reported` or `investigating`), returned here as a 422 state conflict.

        Routes through the shared `Incident#reassign_investigator!` — the SAME
        canonical door the desktop and mobile controllers use: it preserves the
        investigation start (a reassignment does not restart the clock), recomputes
        the deadline, seeds the investigation row only when none exists yet, and
        notifies the new investigator. The response is the full incident detail
        envelope, re-read so it reflects the fresh assignment.

        Requires the **write** scope `write:safety_hub` (assigning an investigator
        is a management action, so the wide scope is required; a narrow
        `write:own_safety_hub` token is refused). Gated on the tenant's
        `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - investigator_id
              properties:
                investigator_id:
                  type: integer
                  description: Id of the active safety admin/manager to assign as
                    investigator.
      responses:
        '200':
          description: Investigator assigned; the incident detail envelope is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '400':
          description: Bad request — investigator_id missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the incident is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Incidents module disabled
        '404':
          description: Incident not found (or not in the caller's business)
        '422':
          description: Unprocessable — the incident is closed/cancelled (its investigator
            is frozen), or investigator_id is not an active admin/manager of this
            business
    put:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Assign or reassign an incident's investigator (PUT alias)
      description: |
        PUT alias of the PATCH above — identical behaviour, for native clients that
        prefer PUT for an idempotent single-field assignment.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - investigator_id
              properties:
                investigator_id:
                  type: integer
                  description: Id of the active safety admin/manager to assign as
                    investigator.
      responses:
        '200':
          description: Investigator assigned; the incident detail envelope is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '400':
          description: Bad request — investigator_id missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the incident is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Incidents module disabled
        '404':
          description: Incident not found (or not in the caller's business)
        '422':
          description: Unprocessable — the incident is closed/cancelled (its investigator
            is frozen), or investigator_id is not an active admin/manager of this
            business
  "/safety_hub/incidents/{id}/complete_investigation":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Complete an incident's investigation
      description: |
        Record an incident investigation's conclusions and close it out — the
        mockup's incident-detail manager panel "Complete Investigation" form, and
        the native twin of the desktop
        `Apps::SafetyHub::IncidentsController#complete_investigation`.

        **Authority is MANAGER-ONLY within accessible sites**, unlike editing an
        incident (which the reporter may also do). The web offers this control to
        no persona but a **safety-hub manager** whose accessible sites include the
        incident — exactly the detail read's `permissions.can_manage_investigation`
        flag — so the API gates the same way: `safety_hub_manager?` AND the
        incident is within the caller's accessible sites. Because a completion is
        always a management action, the wide **`write:safety_hub`** scope is
        required unconditionally — an own-scoped `write:own_safety_hub` token is
        refused (there is no "own" narrow case here). Gated on the tenant's
        `incidents_enabled` module toggle.

        `findings` is **required** (an investigation record with no findings is not
        an investigation record, and the completion cannot be undone);
        `corrective_actions` is optional and round-trips to the investigation's
        `recommendations`. Both flow through `Incident#complete_investigation!` —
        the same door the web uses — which, in one transaction, advances the
        incident to `investigation_completed`, records the findings on the
        `IncidentInvestigation` row (seeding one and resolving an investigator when
        none is assigned), and writes the investigation-findings audit note onto
        the activity timeline.

        The completion is **one-way** — there is no reopen route and the edit door
        cannot walk `status` back — so an incident that is not `investigating`
        (still `reported`, or already `investigation_completed` / `closed` /
        `cancelled`) is refused with **422** rather than re-stamped over its own
        findings. The response is the full incident detail (with the investigation
        narrative, visible to the completing manager as a PII viewer) plus the
        viewer capability flags.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - findings
              properties:
                findings:
                  type: string
                  description: The investigation's conclusions. Required and non-blank.
                corrective_actions:
                  type: string
                  description: Corrective actions taken, recorded as the investigation's
                    `recommendations`. Optional.
      responses:
        '200':
          description: Investigation completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a site manager, the incident is outside the
            caller's sites, an own-scoped token, or Safety Hub / the Incidents module
            disabled
        '404':
          description: Incident not found
        '422':
          description: Unprocessable — findings are blank, the incident is not under
            investigation (already completed / not yet started), or no investigator
            could be resolved to record the findings against
  "/safety_hub/incidents/{id}/cancel":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Cancel a duplicate or mistaken incident report
      description: |
        The API twin of the desktop and phone Cancel. The reporter may cancel
        their own report while it is still `reported`; a site manager may
        cancel one within their sites (`Incident#cancellable_by?`). A closed,
        OSHA-recordable or WCB-reportable case, or one with open corrective
        actions, is refused with the reason. The reason goes on the incident's
        timeline, an open investigation is closed with it, and the report leaves
        every incident total. A cancel is a final act, so the token needs
        **`destructive:safety_hub`**; the detail read's `permissions.can_cancel`
        says whether this door is open to the caller.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - cancellation_reason
              properties:
                cancellation_reason:
                  type: string
                  description: Why the report is cancelled. Required and non-blank.
      responses:
        '200':
          description: Report cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  incident:
                    "$ref": "#/components/schemas/IncidentDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubIncidentPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not its reporter (while Reported) or a site manager,
            the case cannot be cancelled, or the token lacks destructive:safety_hub
        '404':
          description: Incident not found
        '422':
          description: Unprocessable — the reason is blank
  "/safety_hub/observations":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List safety observations
      description: |
        Personal feed by DEFAULT: observations the caller submitted. Pass
        `team=true` for the business-wide feed, which requires Safety Hub
        manager access.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` for the business-wide feed (manager-gated). Omit or
          set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: category
        in: query
        schema:
          type: string
        description: Free-form category (e.g., PPE, Housekeeping)
      - name: observation_type
        in: query
        schema:
          type: string
          enum:
          - positive
          - at_risk
          - near_miss
      - name: status
        in: query
        schema:
          type: string
          enum:
          - submitted
          - under_review
          - resolved
          - closed
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of observations
          content:
            application/json:
              schema:
                type: object
                properties:
                  observations:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyObservation"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Submit a safety observation
      description: |
        Submit a new safety observation — the native "My Submission" → camera →
        "Continue without photo" → incident form → tap **Observation** flow. The
        API twin of the web `Apps::SafetyHub::SafetyObservationsController#create`
        and mobile `#create_observation`.

        **Any member may file one**; the caller is recorded as the observer. Only
        `observation_type`, `category` and `description` are required — a phone
        can post the bare observation form and **add photos later**. `observed_at`
        defaults to now when omitted.

        Photos ride in as multipart `photos[]` and are stored as MediaItem (the
        AI-vision / EXIF-strip pipeline fires and the detail timeline renders
        them). A photo that fails AFTER the observation is saved is reported in
        the `warnings` array rather than failing the submission. An **at-risk** or
        **near-miss** observation notifies the site's managers and safety officers
        (a positive observation is a commendation, not an alert).

        `anonymous` is honoured **only** when the tenant enables
        `allow_anonymous_observations`; otherwise the observer is always recorded.
        A `location_id` or `safety_observation_campaign_id` the caller's tenant
        does not own is dropped (not a 422). `status` cannot be set here — a new
        observation always starts `submitted`.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        for a frontline employee token. Gated on the tenant's
        `observations_enabled` module toggle.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                safety_observation:
                  "$ref": "#/components/schemas/SafetyObservationCreate"
                anonymous:
                  type: boolean
                  description: |
                    Hide the observer identity. Honoured only when the tenant
                    enables anonymous observations.
                photos:
                  type: array
                  description: Photo/video uploads attached to the observation (optional).
                  items:
                    type: string
                    format: binary
          application/json:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                safety_observation:
                  "$ref": "#/components/schemas/SafetyObservationCreate"
                anonymous:
                  type: boolean
      responses:
        '201':
          description: Observation submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  warnings:
                    type: array
                    description: Per-photo attachment failures, if any (the observation
                      was still saved).
                    items:
                      type: string
        '400':
          description: Bad request — the safety_observation object is missing entirely
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — a read-only token, or Safety Hub / the Observations
            module disabled
        '422':
          description: Unprocessable — a required field is blank or invalid
          content:
            application/json:
              schema:
                type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: string
  "/safety_hub/observations/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one safety observation
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Observation detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyObservationPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Observation not found
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Edit a safety observation
      description: |
        Edit ONE observation — the mockup's observation-detail "⋯" menu → "Edit
        observation", and the native twin of the desktop
        `SafetyObservationsController#update`.

        **Authority** mirrors the desktop edit gate: the **observer** may correct
        their OWN observation, and a **site manager** may correct anyone's within
        their accessible sites. A member editing their own observation needs only
        the narrow `write:own_safety_hub` scope; editing another user's via manager
        privilege needs the wide `write:safety_hub` (an own-scoped token acting
        beyond itself is refused). Gated on the tenant's `observations_enabled`
        module toggle.

        **`status`** is accepted only from a manager — a member's value is dropped
        (workflow state is not theirs to mass-assign). A manager setting `status`
        to `closed` closes the observation — a final act (owner decision
        2026-10-04, #6721): that request also requires `destructive:safety_hub`,
        on top of the write scope. **`anonymous`** is a
        ONE-WAY toggle: a reporter may anonymise an existing observation when the
        tenant enables `allow_anonymous_observations`, but never de-anonymise.
        **Photos** posted as multipart `photos[]` are ADDED through MediaItem
        (never replace the existing set); an upload that fails after the edit is
        committed is reported in `warnings` rather than failing the request.

        `PUT` is accepted as an alias of `PATCH`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                anonymous:
                  type: boolean
                  description: One-way — anonymise the observation (tenant policy
                    permitting).
                safety_observation:
                  type: object
                  properties:
                    observation_type:
                      type: string
                      enum:
                      - positive
                      - at_risk
                      - near_miss
                    category:
                      type: string
                    description:
                      type: string
                    action_taken:
                      type: string
                    follow_up_required:
                      type: boolean
                    follow_up_notes:
                      type: string
                    specific_location:
                      type: string
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is ignored).
                    safety_observation_campaign_id:
                      type: integer
                    observed_at:
                      type: string
                      format: date-time
                    status:
                      type: string
                      enum:
                      - submitted
                      - under_review
                      - resolved
                      - closed
                      description: Manager-only; ignored for a member.
          multipart/form-data:
            schema:
              type: object
              properties:
                anonymous:
                  type: boolean
                photos[]:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Photos/videos to ADD to the observation.
                safety_observation:
                  type: object
      responses:
        '200':
          description: Observation updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyObservationPermissions"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Non-fatal issues (e.g. a photo that could not be
                      attached).
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the observer/manager, an own-scoped token reaching
            beyond itself, or Safety Hub / the Observations module disabled
        '404':
          description: Observation not found
        '422':
          description: Unprocessable — validation failed (e.g. a blank description)
    put:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Edit a safety observation (alias of PATCH)
      description: |
        Edit ONE observation — the mockup's observation-detail "⋯" menu → "Edit
        observation", and the native twin of the desktop
        `SafetyObservationsController#update`.

        **Authority** mirrors the desktop edit gate: the **observer** may correct
        their OWN observation, and a **site manager** may correct anyone's within
        their accessible sites. A member editing their own observation needs only
        the narrow `write:own_safety_hub` scope; editing another user's via manager
        privilege needs the wide `write:safety_hub` (an own-scoped token acting
        beyond itself is refused). Gated on the tenant's `observations_enabled`
        module toggle.

        **`status`** is accepted only from a manager — a member's value is dropped
        (workflow state is not theirs to mass-assign). A manager setting `status`
        to `closed` closes the observation — a final act (owner decision
        2026-10-04, #6721): that request also requires `destructive:safety_hub`,
        on top of the write scope. **`anonymous`** is a
        ONE-WAY toggle: a reporter may anonymise an existing observation when the
        tenant enables `allow_anonymous_observations`, but never de-anonymise.
        **Photos** posted as multipart `photos[]` are ADDED through MediaItem
        (never replace the existing set); an upload that fails after the edit is
        committed is reported in `warnings` rather than failing the request.

        `PUT` is accepted as an alias of `PATCH`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - safety_observation
              properties:
                anonymous:
                  type: boolean
                  description: One-way — anonymise the observation (tenant policy
                    permitting).
                safety_observation:
                  type: object
                  properties:
                    observation_type:
                      type: string
                      enum:
                      - positive
                      - at_risk
                      - near_miss
                    category:
                      type: string
                    description:
                      type: string
                    action_taken:
                      type: string
                    follow_up_required:
                      type: boolean
                    follow_up_notes:
                      type: string
                    specific_location:
                      type: string
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is ignored).
                    safety_observation_campaign_id:
                      type: integer
                    observed_at:
                      type: string
                      format: date-time
                    status:
                      type: string
                      enum:
                      - submitted
                      - under_review
                      - resolved
                      - closed
                      description: Manager-only; ignored for a member.
          multipart/form-data:
            schema:
              type: object
              properties:
                anonymous:
                  type: boolean
                photos[]:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Photos/videos to ADD to the observation.
                safety_observation:
                  type: object
      responses:
        '200':
          description: Observation updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyObservationPermissions"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Non-fatal issues (e.g. a photo that could not be
                      attached).
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the observer/manager, an own-scoped token reaching
            beyond itself, or Safety Hub / the Observations module disabled
        '404':
          description: Observation not found
        '422':
          description: Unprocessable — validation failed (e.g. a blank description)
  "/safety_hub/observations/{id}/complete_follow_up":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Complete an observation's follow-up
      description: |
        Mark ONE observation's follow-up as complete — the mockup's observation
        detail "Complete Follow-up" button ("Mark this follow-up as complete? The
        observation will be marked resolved."), and the native twin of the desktop
        `SafetyObservationsController#mark_follow_up_complete`.

        **Authority is MANAGER-ONLY**, unlike editing an observation (which the
        observer may also do). The web offers this control to no persona but a
        **safety-hub manager** within their accessible sites, so the API gates the
        same way: `safety_hub_manager?` AND the observation is within the caller's
        accessible sites. Because a follow-up completion is always a management
        action, the wide **`write:safety_hub`** scope is required unconditionally —
        an own-scoped `write:own_safety_hub` token is refused (there is no "own"
        narrow case here). Gated on the tenant's `observations_enabled` module
        toggle.

        Routes through `SafetyObservation#mark_follow_up_complete!` — the same door
        the web uses — so it stamps `follow_up_completed_at` + `follow_up_completed_by`
        and advances `status` to `resolved` unless the observation is already
        `closed` (a closed observation records the completion but is NOT reopened).
        A follow-up that is not outstanding is refused with **422**: either the
        observation has no follow-up flagged, or it was already completed (the
        original completer/timestamp audit is never overwritten). Takes no request
        body.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Follow-up completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  observation:
                    "$ref": "#/components/schemas/SafetyObservationDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyObservationPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the observation is outside the caller's
            sites, an own-scoped token, or Safety Hub / the Observations module disabled
        '404':
          description: Observation not found
        '422':
          description: Unprocessable — no outstanding follow-up (none flagged, or
            already completed), or the observation is not in a completable state
  "/safety_hub/toolbox_talks":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List toolbox talks (personal or team)
      description: |
        Default (`team` absent or false) — **personal feed**: only talks where
        the current user is the facilitator OR appears in the talk's
        `expected_attendees` JSONB array. Sorted by `scheduled_at` ASC.

        Note: this is narrower than the desktop "My Toolbox Talks" view at
        `/apps/safety-hub/toolbox_talks/my`, which additionally surfaces past
        talks the user attended without being pre-listed (via
        `toolbox_talk_attendances`) and sorts newest first. The API
        deliberately scopes to facilitator + expected-attendee only.

        `team=true` — **team feed**: all toolbox talks in the business.
        Requires manager-level access (`manager_or_above?` OR safety-hub
        app-admin), matching the web "Toolbox Talks" admin surface. Sorted by
        `scheduled_at` DESC.

        Combinable with `status` and `scope` filters in either mode.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` to request the team feed (manager-gated). Omit or
          set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: Filter by lifecycle status
        schema:
          type: string
          enum:
          - scheduled
          - in_progress
          - completed
          - cancelled
      - name: scope
        in: query
        description: Filter by lifecycle scope
        schema:
          type: string
          enum:
          - upcoming
          - completed
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of toolbox talks
          content:
            application/json:
              schema:
                type: object
                properties:
                  toolbox_talks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ToolboxTalk"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled, no access, or team=true requested
            by a non-manager
  "/safety_hub/toolbox_talks/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one toolbox talk
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Toolbox talk detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  toolbox_talk:
                    "$ref": "#/components/schemas/ToolboxTalkDetail"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Toolbox talk not found
  "/safety_hub/topics":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List toolbox talk topics
      description: Active toolbox-talk topic templates for the current business.
      parameters:
      - name: category
        in: query
        schema:
          type: string
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of topics
          content:
            application/json:
              schema:
                type: object
                properties:
                  topics:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ToolboxTalkTopic"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
  "/safety_hub/certifications":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List certification requirements
      description: Returns certification requirements (templates) configured for the
        business.
      parameters:
      - name: category
        in: query
        schema:
          type: string
      - name: include_inactive
        in: query
        description: Set to "true" to include inactive requirements (otherwise only
          active are returned)
        schema:
          type: string
          enum:
          - 'true'
          - 'false'
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of certification requirements
          content:
            application/json:
              schema:
                type: object
                properties:
                  certifications:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyCertificationRequirement"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
  "/safety_hub/submissions":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Submissions feed (personal or team)
      description: |
        Returns a unified incidents + observations feed, sorted by
        `submitted_at` (record `created_at`) descending — most recent
        submissions first. Each row ships UI-ready metadata (icon, label,
        color tokens) so native-mobile clients don't have to re-derive the
        visual treatment.

        Default (`team` absent or false) — **personal feed**: only rows the
        current user reported/submitted, with anonymous rows excluded. Mirrors
        the desktop `/apps/safety-hub/submitted_by_me` action.

        `team=true` — **team feed**: all incidents and observations in the
        business, including anonymous rows. Requires manager-level access
        (`manager_or_above?` OR safety-hub app-admin), matching the web
        "Team > Incidents/Observations" surface.

        The response also includes a `summary` block with current-month tile
        counts whose scope follows the `team` parameter: the personal feed
        returns the current user's own submissions (anonymous excluded), and
        the team feed returns every submission in the business (anonymous
        included) — so the tiles always match the rows the user is looking
        at. Each tile carries a `label`, `count`, `color` (Bootstrap token),
        and `icon` (stable icon-name token) so clients can render headline
        metrics without computing them.

        Honors the per-module toggles (`incidents_enabled`,
        `observations_enabled`) from the Safety Hub marketplace-app
        configuration in both scopes; disabled modules contribute `0` to the
        summary and emit no rows.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      - name: team
        in: query
        description: |
          Set to `true` to request the team feed (manager-gated). Omit or
          set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: kind
        in: query
        description: |
          Optional filter that restricts the returned rows to a single
          submission kind. Omit to return both incidents and observations
          (default). Unknown values are ignored (both are returned). The
          `summary` tile counts always include BOTH incidents and
          observations (subject to the per-module toggles) regardless of
          this filter, so headline counts stay stable as clients toggle
          between kinds.
        required: false
        schema:
          type: string
          enum:
          - incident
          - observation
      responses:
        '200':
          description: Submission feed
          content:
            application/json:
              schema:
                type: object
                properties:
                  submissions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubSubmission"
                  summary:
                    "$ref": "#/components/schemas/SafetyHubSubmissionsSummary"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
                  own_injury_records:
                    type: array
                    description: Personal feed only — incidents someone else reported
                      that name the caller as an injured person (up to 10, newest
                      first). Empty for team=true.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        reference:
                          type: string
                        status:
                          type: string
                        occurred_at:
                          type: string
                          format: date-time
                          nullable: true
                        location:
                          type: object
                          nullable: true
                          properties:
                            name:
                              type: string
                        url:
                          type: string
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled, no access, or team=true requested
            by a non-manager
  "/safety_hub/corrective_actions":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Corrective actions register (personal or team)
      description: |
        The unified Corrective & Preventive Action register (Capa::Action) —
        the native mirror of the desktop
        `Apps::SafetyHub::CorrectiveActionsController#index`.

        Default (`team` absent or false) — **personal feed** ("My Corrective
        Actions"): the corrective/preventive actions ASSIGNED TO the caller.
        Mirrors the desktop `#visible_scope`'s non-manager branch. Leader Rounds
        issues are excluded (that register owns its own transitions).

        `team=true` — **team feed**: the whole register, requiring manager-level
        access (`manager_or_above?` OR safety-hub app-admin) and site-scoped
        identically to the desktop board (a site-restricted manager sees actions
        at their accessible sites, site-less actions, and anything assigned to
        them). An own-scoped token (`read:own_safety_hub` without
        `read:safety_hub`) is bounded to the personal feed.

        Gated on the tenant's `incidents_enabled` module toggle, exactly as the
        desktop register and its navigation item are.

        Rows are ordered open-work-first, then by due date ascending, then id
        descending — the register's shared ordering authority
        (`Capa::Action.open_first_order`) so this feed and the web open on the
        same first screen.
      parameters:
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      - name: team
        in: query
        description: Set to `true` for the team feed (manager-gated). Omit/false for
          the personal feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: |
          A real status (`pending`, `in_progress`, `completed`, `cancelled`),
          or one of the pseudo-statuses `open` (pending+in_progress), `closed`
          (completed+cancelled), `overdue` (open actions past their due date),
          or `all` (no status filter). Omitted → no status filter. An
          unrecognized value returns 400. `overdue` is the native filter row's
          own chip (All · Pending · In Progress · Overdue · Completed ·
          Cancelled), so the whole single-select row can be driven through
          `status`; it maps to the same scope as the `overdue=true` flag.
        schema:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          - open
          - closed
          - overdue
          - all
      - name: overdue
        in: query
        description: |
          Set to `true` to return only open actions past their due date.
          Combinable with a real `status` (e.g. `status=in_progress&overdue=true`);
          on its own it is equivalent to `status=overdue`.
        schema:
          type: boolean
          default: false
      - name: priority
        in: query
        description: One of low/medium/high/critical. Unrecognized values are ignored
          (no filter applied).
        schema:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
      - name: action_type
        in: query
        description: One of the Capa action types. Unrecognized values are ignored.
        schema:
          type: string
          enum:
          - immediate
          - short_term
          - long_term
          - preventive
          - training
          - policy_change
          - equipment
          - environmental
      - name: assigned_to_id
        in: query
        description: |
          Team feed only (`team=true`) — narrow the board to a single
          assignee. Ignored in the personal feed (already scoped to the caller).
        schema:
          type: integer
      responses:
        '200':
          description: Corrective actions register slice
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_actions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubCorrectiveAction"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '400':
          description: Bad request — unrecognized status value
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub or the Incidents module disabled, no
            access, or team=true requested by a non-manager
  "/safety_hub/corrective_actions/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one corrective action
      description: |
        Full detail for ONE corrective/preventive action (Capa::Action) — the
        native "My Corrective Actions" detail screen: the priority/status/type
        chips, the Action Details card (source, assignee, due date, description),
        the Completion record, and the ISO 45001 effectiveness-verification
        record.

        Visible to exactly the personas the desktop
        `Apps::SafetyHub::CorrectiveActionsController#visible_scope` admits: the
        action's **assignee**, or a **safety-hub manager** whose accessible
        sites include it (incident-sourced rows inherit their incident's site;
        other rows are scoped by `location_id` with site-less rows kept; plus
        anything assigned directly to the caller). Leader Rounds-sourced actions
        are excluded (that ledger owns its own detail surface). An action the
        caller may not reach returns 404 — it never confirms the row exists.

        Beyond the list card, the payload adds `verified_by`,
        `effectiveness_notes` and `days_until_due`, and a viewer-relative
        `permissions` block so a native client renders the right controls
        (Assign / Mark Complete / Verify) without a second round trip.

        Gated on the tenant's `incidents_enabled` module toggle, exactly as the
        list and the desktop register are.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Corrective action detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub or the Incidents module disabled, or
            no access
        '404':
          description: Corrective action not found (or not visible to the caller)
  "/safety_hub/corrective_actions/{id}/start":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Start work on a corrective action
      description: |
        Start work on ONE corrective/preventive action (Capa::Action): Not
        started (`pending`) → In progress, through `Capa::Action#start!`, which
        records who started it on the action's trail. This is the only way an
        action comes to read In progress — reassigning it never moves the status.
        The desktop action page's Start work, for the native app.

        Authority is the complete endpoint's and the detail endpoint's
        `permissions.can_start`: a **safety-hub manager** OR the action's
        **assignee**, resolved through the same desktop `#visible_scope` (an
        action the caller may not reach is a 404). Only a Not started action can
        be started; anything else is 422.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        for your OWN assigned action. Gated on the tenant's `incidents_enabled`
        module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Work started — the action is In progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the assignee or a manager, an own-scoped token
            acting on another user's action, a token without the write scope, or Safety
            Hub / the Incidents module disabled
        '404':
          description: Corrective action not found (or not visible to the caller)
        '422':
          description: Unprocessable — work has already started, or the action is
            completed/cancelled
  "/safety_hub/corrective_actions/{id}/complete":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Mark a corrective action complete
      description: |
        Close out ONE corrective/preventive action (Capa::Action) from the
        native "My Corrective Actions" detail — the mockup's "Update Action"
        panel, "Mark Complete" button and optional "Completion notes…" box.

        Authority mirrors the desktop
        `Apps::SafetyHub::CorrectiveActionsController#complete` and the detail
        endpoint's `permissions.can_complete`: a **safety-hub manager** OR the
        action's **assignee**, on an **open** (pending / in_progress) action.
        The action is resolved through the same desktop `#visible_scope` the
        detail read uses, so an action the caller may not reach is a 404.

        Routes through `Capa::Action#mark_completed!` — the same door the web
        uses — so an incident-sourced action also logs an `IncidentUpdate` audit
        row on its parent incident, and a cancelled / already-completed action is
        refused (422) rather than resurrected.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        when completing your OWN assigned action. Completing another user's action
        via manager privilege requires the wide `write:safety_hub`. Gated on the
        tenant's `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolution_notes:
                  type: string
                  maxLength: 4000
                  description: |
                    Optional completion notes ("Completion notes…" in the
                    mockup). A blank/omitted value preserves any resolution note
                    already on file; it is never erased.
      responses:
        '200':
          description: Corrective action completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the assignee or a manager, an own-scoped token
            acting on another user's action, or Safety Hub / the Incidents module
            disabled
        '404':
          description: Corrective action not found (or not visible to the caller)
        '422':
          description: Unprocessable — the action is already completed/cancelled,
            or the notes exceed the 4000-character limit
  "/safety_hub/corrective_actions/{id}/assignee":
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Reassign a corrective action
      description: |
        Reassign ONE open corrective/preventive action (Capa::Action) to a
        different business user — the mockup's "Assign to" picker on the "My
        Corrective Actions" detail.

        Authority mirrors the detail endpoint's `permissions.can_assign`:
        **manager-only**, on an **open** (pending / in_progress) action (a member,
        even the current assignee, cannot reassign — it is a management decision).
        A completed / cancelled action's assignee is its "closed out by"
        attribution, so its ownership is frozen and reassignment returns 422.

        Routes through `Capa::Action#assign!` (keeps the status — reassigning is
        not starting — and notifies the new assignee); the model's cross-tenant FK
        guard and this endpoint both reject an assignee who is not a member of the
        business.

        Requires the **write** scope: `write:safety_hub` (reassigning is a manager
        action beyond the caller's own data, so the wide scope is required; a
        narrow `write:own_safety_hub` token is refused). Gated on the tenant's
        `incidents_enabled` module toggle.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - assigned_to_id
              properties:
                assigned_to_id:
                  type: integer
                  description: Id of the business user to assign the action to.
      responses:
        '200':
          description: Corrective action reassigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubCorrectiveActionPermissions"
        '400':
          description: Bad request — assigned_to_id missing
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, an own-scoped token, or Safety Hub
            / the Incidents module disabled
        '404':
          description: Corrective action not found (or not visible to the caller)
        '422':
          description: Unprocessable — the action is closed (its assignee is frozen),
            or assigned_to_id is not a member of this business
  "/safety_hub/permits":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: List permits to work ("My Permits")
      description: |
        The caller's Permits to Work.

        Personal feed by DEFAULT ("My Permits"): permits the caller REQUESTED
        or AUTHORISED. Pass `team=true` for the whole board, which requires
        Safety Hub manager access and is site-scoped to the manager's
        accessible sites (plus site-less permits and anything they are
        personally on).

        Requires the Permits to Work module to be enabled for the business.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` for the whole board (manager-gated + site-scoped).
          Omit or set to `false` for the personal "My Permits" feed.
        schema:
          type: boolean
          default: false
      - name: status
        in: query
        description: |
          Filter by status. Accepts a real permit status
          (`draft`, `requested`, `approved`, `suspended`, `closed`,
          `cancelled`, `expired`) or one of the derived board views:
          `active` (issued and inside its work window), `awaiting`
          (requested / awaiting approval), `overrun` (issued and past its end
          time), or `all` (no status filter). Omitted → the OPEN board
          (draft / requested / approved / suspended), matching the web
          surfaces' default. An unrecognized or malformed value falls back to
          the OPEN board.
        schema:
          type: string
      - name: permit_type
        in: query
        description: |
          Filter by permit type — one of `hot_work`, `confined_space`,
          `working_at_height`, `electrical`, `excavation`, `lifting`,
          `general`.
        schema:
          type: string
          enum:
          - hot_work
          - confined_space
          - working_at_height
          - electrical
          - excavation
          - lifting
          - general
      - name: location_id
        in: query
        description: Narrow the board to a single site.
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Page size, clamped to [1, 100]. Defaults to 25.
        schema:
          type: integer
          default: 25
      responses:
        '200':
          description: Permit board slice
          content:
            application/json:
              schema:
                type: object
                properties:
                  permits:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubPermit"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  scope:
                    type: string
                    enum:
                    - personal
                    - team
                    description: Which scope the response was generated for.
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub or the Permits to Work module disabled,
            no access, or team=true requested by a non-manager
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Raise a draft permit to work
      description: |
        Raise a DRAFT permit to work — the native mirror of the "My Permits"
        board's "Request Permit" (+) button and the API twin of the desktop
        `Apps::SafetyHub::PermitsController#create`. (Mobile-web has no
        permit-create route — permits are requested and authorised on the
        desktop board.)

        **Any member may raise one** — creation is gated on nothing but the
        Permits module toggle, and the web offers the button to every persona
        (only the page title differs: "My Permits" vs "Permits to Work"). The
        caller is always recorded as the **requester** and the permit is born a
        **draft** — issuing it (`request` → a DIFFERENT manager `approve`s) is a
        separate two-person control, never part of creation, so `status` is not
        accepted here.

        `permit_type`, `title`, `starts_at` and `ends_at` are required. A bad
        work window (end before start, or longer than 14 days), a cross-tenant
        `location_id`/`vendor_id`, or a missing field is refused with a 422 and
        the payload is left intact — never a silent save. `permit_number` is
        assigned automatically (`PTW-<year>-NNNN`, per business-year).

        **`precautions`** are the authoriser's confirmations — a non-manager
        caller's `precautions` key is dropped, so a requester can never pre-tick
        on their own draft the controls that gate issue.

        Requires the **write** scope: `write:safety_hub`, or `write:own_safety_hub`
        (the by-hand employee grant). Gated on the tenant's `permits_enabled`
        module toggle.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - work_permit
              properties:
                work_permit:
                  type: object
                  required:
                  - permit_type
                  - title
                  - starts_at
                  - ends_at
                  properties:
                    permit_type:
                      type: string
                      enum:
                      - hot_work
                      - confined_space
                      - working_at_height
                      - electrical
                      - excavation
                      - lifting
                      - general
                    title:
                      type: string
                      maxLength: 255
                    description:
                      type: string
                    area:
                      type: string
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is refused with a 422).
                    vendor_id:
                      type: integer
                      description: Contractor id in the caller's business (a foreign
                        id is refused with a 422).
                    starts_at:
                      type: string
                      format: date-time
                    ends_at:
                      type: string
                      format: date-time
                      description: Must be after starts_at and within 14 days of it.
                    isolations:
                      type: string
                    ppe:
                      type: string
                    gas_test:
                      type: string
                    emergency_arrangements:
                      type: string
                    hazards:
                      type: array
                      items:
                        type: string
                      description: The requester's identified hazards.
                    precautions:
                      type: object
                      additionalProperties:
                        type: boolean
                      description: Authoriser-only — a map of precaution text → confirmed.
                        Dropped for a non-manager caller.
      responses:
        '201':
          description: Draft permit created
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '400':
          description: Bad request — the work_permit payload is missing entirely
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — the token lacks the write scope, or Safety Hub
            / the Permits to Work module is disabled
        '422':
          description: Unprocessable — validation failed (a missing field, an inverted/over-long
            window, or a cross-tenant site/contractor)
  "/safety_hub/permits/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one permit to work
      description: |
        Full permit detail — the validity window, requester (issuer),
        authoriser (approver), the type's hazards and the precaution checklist
        with each control's confirmed state, plus the closure record. Viewable
        by the requester, the authoriser, or a manager whose accessible sites
        include the permit's site (site-less permits stay manager-visible).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Permit detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — you may only view permits you requested, authorised
            or are on the crew of
        '404':
          description: Permit not found
    patch:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Save a permit to work
      description: |
        Save ONE permit's working record — the mockup's permit-detail "Save"
        button, and the native twin of the desktop `PermitsController#update`.

        ONE door, two personas, exactly as the shared web save: the **requester**
        fills the hazard checklist and the control notes (isolations, PPE, gas
        test, emergency arrangements) until the permit is issued, and a **manager**
        (the authoriser) confirms the **precaution** checklist — the gate on
        issue — while the permit is open.

        **Authority** mirrors the desktop edit gate: the **requester** may save
        their OWN permit while it is still `draft`/`requested` (they lose the pen
        once it is issued — the type, window, site and contractor also freeze
        then), and a **site manager** may save any permit within their accessible
        sites while it is open. A member saving their own permit needs only the
        narrow `write:own_safety_hub` scope; saving another user's via manager
        privilege needs the wide `write:safety_hub` (an own-scoped token acting
        beyond itself is refused). Gated on the tenant's `permits_enabled` module
        toggle.

        **`precautions`** are the authoriser's — a non-manager's `precautions`
        key is dropped before the write, so a requester can never sign off their
        own controls. **Issuing** the permit (`draft`/`requested` → `approved`) is
        a SEPARATE control — this action only saves the working record and never
        advances the lifecycle. A cross-tenant `location_id`/`vendor_id` is
        refused with a 422 (the model's belongs-to-business validation).

        `PUT` is accepted as an alias of `PATCH`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - work_permit
              properties:
                work_permit:
                  type: object
                  properties:
                    title:
                      type: string
                    description:
                      type: string
                    area:
                      type: string
                    permit_type:
                      type: string
                      enum:
                      - hot_work
                      - confined_space
                      - working_at_height
                      - electrical
                      - excavation
                      - lifting
                      - general
                      description: Frozen once the permit is issued.
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is refused with a 422). Frozen once issued.
                    vendor_id:
                      type: integer
                      description: Contractor id in the caller's business. Frozen
                        once issued.
                    starts_at:
                      type: string
                      format: date-time
                      description: Frozen once issued.
                    ends_at:
                      type: string
                      format: date-time
                      description: Frozen once issued.
                    isolations:
                      type: string
                    ppe:
                      type: string
                    gas_test:
                      type: string
                    emergency_arrangements:
                      type: string
                    hazards:
                      type: array
                      items:
                        type: string
                      description: The requester's identified hazards (replaces the
                        set).
                    precautions:
                      type: object
                      additionalProperties:
                        type: boolean
                      description: Authoriser-only — a map of precaution text → confirmed.
                        Dropped for a non-manager caller. Only controls in the permit
                        type's catalog are recorded.
      responses:
        '200':
          description: Permit saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            an own-scoped token reaching beyond itself, or Safety Hub / the Permits
            module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — validation failed (e.g. a blank title, or a
            cross-tenant / frozen field)
    put:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Save a permit to work (alias of PATCH)
      description: |
        Save ONE permit's working record — the mockup's permit-detail "Save"
        button, and the native twin of the desktop `PermitsController#update`.

        ONE door, two personas, exactly as the shared web save: the **requester**
        fills the hazard checklist and the control notes (isolations, PPE, gas
        test, emergency arrangements) until the permit is issued, and a **manager**
        (the authoriser) confirms the **precaution** checklist — the gate on
        issue — while the permit is open.

        **Authority** mirrors the desktop edit gate: the **requester** may save
        their OWN permit while it is still `draft`/`requested` (they lose the pen
        once it is issued — the type, window, site and contractor also freeze
        then), and a **site manager** may save any permit within their accessible
        sites while it is open. A member saving their own permit needs only the
        narrow `write:own_safety_hub` scope; saving another user's via manager
        privilege needs the wide `write:safety_hub` (an own-scoped token acting
        beyond itself is refused). Gated on the tenant's `permits_enabled` module
        toggle.

        **`precautions`** are the authoriser's — a non-manager's `precautions`
        key is dropped before the write, so a requester can never sign off their
        own controls. **Issuing** the permit (`draft`/`requested` → `approved`) is
        a SEPARATE control — this action only saves the working record and never
        advances the lifecycle. A cross-tenant `location_id`/`vendor_id` is
        refused with a 422 (the model's belongs-to-business validation).

        `PUT` is accepted as an alias of `PATCH`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - work_permit
              properties:
                work_permit:
                  type: object
                  properties:
                    title:
                      type: string
                    description:
                      type: string
                    area:
                      type: string
                    permit_type:
                      type: string
                      enum:
                      - hot_work
                      - confined_space
                      - working_at_height
                      - electrical
                      - excavation
                      - lifting
                      - general
                      description: Frozen once the permit is issued.
                    location_id:
                      type: integer
                      description: Site id in the caller's business (a foreign id
                        is refused with a 422). Frozen once issued.
                    vendor_id:
                      type: integer
                      description: Contractor id in the caller's business. Frozen
                        once issued.
                    starts_at:
                      type: string
                      format: date-time
                      description: Frozen once issued.
                    ends_at:
                      type: string
                      format: date-time
                      description: Frozen once issued.
                    isolations:
                      type: string
                    ppe:
                      type: string
                    gas_test:
                      type: string
                    emergency_arrangements:
                      type: string
                    hazards:
                      type: array
                      items:
                        type: string
                      description: The requester's identified hazards (replaces the
                        set).
                    precautions:
                      type: object
                      additionalProperties:
                        type: boolean
                      description: Authoriser-only — a map of precaution text → confirmed.
                        Dropped for a non-manager caller. Only controls in the permit
                        type's catalog are recorded.
      responses:
        '200':
          description: Permit saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            an own-scoped token reaching beyond itself, or Safety Hub / the Permits
            module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — validation failed (e.g. a blank title, or a
            cross-tenant / frozen field)
  "/safety_hub/permits/{id}/request_approval":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Send a permit for approval
      description: |
        Send ONE draft permit for approval — the mockup's permit-detail
        "Request approval" button (shown for a `draft` permit to a caller who may
        edit it), and the native twin of the desktop
        `Apps::SafetyHub::PermitsController#request_approval`. Moves the permit
        `draft` → `requested` and notifies the authorisers, exactly as the web does.

        **Authority is the SAME door the save uses** — the desktop wires both
        "Request approval" and the working-record save behind one edit gate — so
        this action gates identically: the **requester** while the permit is still
        `draft`/`requested` (own data → the narrow `write:own_safety_hub` scope
        suffices), OR a **site manager** whose accessible sites include the permit
        while it is open (acting on another user's permit via manager privilege
        needs the wide `write:safety_hub` — an own-scoped token reaching beyond
        itself is refused). Gated on the tenant's `permits_enabled` module toggle.

        The lifecycle guard is the model's (`WorkPermit#request!`): only a **draft**
        can be requested. A `requested`/`approved`/`suspended`/`closed`/`cancelled`/
        `expired` permit is refused with **422** — the button's own draft-only
        visibility is enforced server-side and never trusted from the client. Takes
        no request body.

        The response re-reads the full permit detail (same eager-load set as the
        detail read — no N+1) with the refreshed viewer permissions, so the client
        repaints the detail screen — the new `requested` state and the now-absent
        Request-approval affordance — without a second round trip.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Approval requested (permit is now `requested`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            an own-scoped token reaching beyond itself, or Safety Hub / the Permits
            module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — the permit is not a draft (only a draft can
            be sent for approval)
  "/safety_hub/permits/{id}/approve":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Issue (approve) a permit
      description: |
        Issue ONE requested permit — the mockup's permit-detail "Approve / issue"
        control, and the native twin of the desktop
        `Apps::SafetyHub::PermitsController#approve`. Moves a `requested` permit →
        `approved`, stamping the **authoriser** (`approved_by`), the timestamp and
        any `approval_notes`, and (for a contractor permit) the current
        pre-qualification. The permit becomes live inside its work window.

        **Manager-only within accessible sites** — the desktop wires approve behind
        `authorize_safety_hub_manager!` + `authorize_permit_authoriser!`
        (authorising a permit is a manager act at the permit's site), the SAME
        chain suspend and resume use. A non-manager is refused, as is a site-scoped
        manager whose accessible sites exclude the permit. The wide
        `write:safety_hub` scope is required **unconditionally** — issuing is
        always a management action, so there is no own-scoped case. Gated on the
        tenant's `permits_enabled` module toggle.

        **Two-person control:** a permit is a two-person control, so the model
        refuses a requester authorising their OWN permit (`by == requested_by`).
        Issuing also requires **every precaution confirmed**, the work window not
        yet ended, and — for a contractor permit, when the tenant requires it — a
        current pre-qualification; any of these is a **422**
        (`WorkPermit#approvable_reasons`), and only a `requested` permit can be
        issued at all (a `draft`/`approved`/`suspended`/`closed`/`cancelled`/
        `expired` permit is likewise a 422).

        The authoriser may confirm the **last outstanding precautions in the same
        POST** by sending a `work_permit` payload (hazards / precaution checklist /
        control notes) — saved first, then the permit is issued. On success the
        requester is notified their permit was issued
        (`notify_permit_approved`). The response re-reads the full permit detail
        (same eager-load set as the detail read — no N+1) with the refreshed viewer
        permissions.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                approval_notes:
                  type: string
                  description: Optional free-text notes recorded on `approval_notes`.
                proceed_anyway:
                  type: boolean
                  description: Required (true) when the contractor has certificate
                    gaps (`certificate_gaps` on the permit detail) — the explicit
                    confirmation to issue regardless. Ignored when there are none.
                certificate_override_reason:
                  type: string
                  maxLength: 2000
                  description: Required, non-blank, with `proceed_anyway` when the
                    contractor has certificate gaps. Stored on the permit with the
                    gaps confirmed, who and when (`certificate_override`).
                confirmed_gap_keys:
                  type: array
                  items:
                    type: string
                  description: 'Required when the contractor has certificate gaps:
                    the `key` of every gap you showed the user (from `certificate_gaps`
                    on the permit detail). A live gap missing from this list is refused
                    (`certificate_gaps_changed: true`) rather than recorded as confirmed
                    unseen; a listed gap that has since cleared is simply not recorded.'
                work_permit:
                  type: object
                  description: Optional — confirm the last precautions (and any working
                    fields) in the same request before issuing. Only a manager reaches
                    this action, so these are always the authoriser's to tick.
                  properties:
                    hazards:
                      type: array
                      items:
                        type: string
                    precautions:
                      type: object
                      additionalProperties:
                        type: boolean
                      description: Map of the permit type's precaution text → confirmed
                        (true). Only keys in the type's catalog are recorded.
                    isolations:
                      type: string
                    ppe:
                      type: string
                    gas_test:
                      type: string
                    emergency_arrangements:
                      type: string
      responses:
        '200':
          description: Permit issued (permit is now `approved`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the permit is at a site the manager
            does not manage, an own-scoped token (wide scope required), or Safety
            Hub / the Permits module disabled
        '404':
          description: Permit not found
        '422':
          description: 'Unprocessable — the permit is not awaiting approval, the requester
            cannot authorise their own permit, precautions are outstanding, the window
            has ended, the contractor is not pre-qualified, or the contractor has
            certificate gaps and `proceed_anyway` + `certificate_override_reason`
            + `confirmed_gap_keys` did not confirm them all (then the body carries
            `confirmation_required: true`, `certificate_gaps_changed` and the current
            `certificate_gaps`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SafetyHubPermitCertificateGapRefusal"
  "/safety_hub/permits/{id}/cancel":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Cancel a permit
      description: |
        Cancel ONE permit to work — the mockup's permit-detail red "Cancel"
        control (shown for a non-terminal permit to a caller who may edit it), and
        the native twin of the desktop `Apps::SafetyHub::PermitsController#cancel`.
        Cancellation is **terminal**: it flips any still-open permit
        (`draft`/`requested`/`approved`/`suspended`) straight to `cancelled`,
        records the reason, and notifies the permit holders (requester +
        authoriser, except the actor), exactly as the web does. It is the "scrap
        this permit" door — because the model freezes a permit's core fields once
        it is issued, the sanctioned way to undo an issued permit is to cancel it
        and raise a fresh one.

        **Authority is the SAME door the save/request use** — the desktop wires
        cancel, the working-record save, and "Request approval" behind one edit
        gate — so this action gates identically: the **requester** while the permit
        is still `draft`/`requested` (own data → the narrow `write:own_safety_hub`
        scope suffices), OR a **site manager** whose accessible sites include the
        permit while it is open (acting on another user's permit via manager
        privilege needs the wide `write:safety_hub` — an own-scoped token reaching
        beyond itself is refused). A `closed`/`cancelled`/`expired` permit is
        cancellable by nobody — both arms of the gate reject it (**403**). Gated on
        the tenant's `permits_enabled` module toggle.

        The response re-reads the full permit detail (same eager-load set as the
        detail read — no N+1) with the refreshed viewer permissions, so the client
        repaints the detail screen — the new `cancelled` state and `cancelled_reason`
        — without a second round trip.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:safety_hub` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional free-text reason recorded on `cancelled_reason`.
                    Defaults to "Cancelled by {name}" when blank.
      responses:
        '200':
          description: Permit cancelled (permit is now `cancelled`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester (before issue) or a manager,
            the permit is already closed/cancelled/expired, an own-scoped token reaching
            beyond itself, or Safety Hub / the Permits module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — the permit could not be cancelled (e.g. a concurrent
            close/expire made it terminal first)
  "/safety_hub/permits/{id}/close":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Close a permit
      description: |
        Close out ONE issued permit — the mockup's permit-detail "Close permit"
        control, and the native twin of the desktop
        `Apps::SafetyHub::PermitsController#close`. This is the clean **sign-off**
        of a completed job (distinct from cancel, the "scrap it" door): the caller
        confirms the area was left safe and the permit moves
        `approved`/`suspended`/`expired` → `closed`, recording the closer, the
        timestamp and any closure notes. An `expired` permit is one the hourly sweep
        retired a day after its end time because nobody closed it out; its close-out
        is still owed, so it stays closable (`permissions.can_close`).

        **Authority** is the desktop close gate exactly — which admits a persona
        the edit door does not, the **authoriser**: the **requester**, the
        **authoriser** (`approved_by`), or a **safety_hub_manager** at the permit's
        site may close. A
        participant (requester or authoriser) closing their OWN permit needs only
        the narrow `write:own_safety_hub` scope; a manager closing a permit they
        are not personally on acts via manager privilege and needs the wide
        `write:safety_hub`. Gated on the tenant's `permits_enabled` module toggle.

        `area_left_safe` must be confirmed **true** — a false/absent value is
        refused with **422** (`WorkPermit#close!` requires it), never a silent
        close. Only an **approved**, **suspended** or **expired** permit can be
        closed; a `draft`/`requested`/`closed`/`cancelled` permit is a **422**.
        Close fires **no notification**. The response re-reads the full permit
        detail (same eager-load set as the detail read — no N+1) with the refreshed
        viewer permissions, so the client repaints the detail — the `closed` state
        and closure record — without a second round trip.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:safety_hub` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - area_left_safe
              properties:
                area_left_safe:
                  type: boolean
                  description: Must be true — confirms the work area was left safe.
                    A false/absent value is refused with 422.
                closure_notes:
                  type: string
                  description: Optional free-text closure notes recorded on the permit.
      responses:
        '200':
          description: Permit closed (permit is now `closed`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not the requester, the authoriser or a manager,
            an own-scoped token reaching beyond itself, or Safety Hub / the Permits
            module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — area_left_safe was not confirmed, or the permit
            is not approved/suspended/expired (only an issued, suspended or expired
            permit can be closed)
  "/safety_hub/permits/{id}/suspend":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Suspend a permit
      description: |
        Suspend ONE issued permit — the mockup's permit-detail "Suspend" control,
        and the native twin of the desktop
        `Apps::SafetyHub::PermitsController#suspend`. Flips an issued (`approved`)
        permit → `suspended` (work must stop) and records the reason; the permit
        stays **open** and can later be resumed or closed.

        **Manager-only within accessible sites** — the desktop wires suspend behind
        `authorize_safety_hub_manager!` + `authorize_permit_authoriser!`
        (authorising a permit is a manager act at the permit's site). A non-manager
        is refused, as is a site-scoped manager whose accessible sites exclude the
        permit. The wide `write:safety_hub` scope is required **unconditionally** —
        a suspension is always a management action, so there is no own-scoped case.
        Gated on the tenant's `permits_enabled` module toggle. Offered to the
        client as `permissions.can_suspend`, which is false for any caller or
        token this endpoint would refuse.

        Only an **issued (approved)** permit can be suspended; a
        `draft`/`requested`/`suspended`/`closed`/`cancelled`/`expired` permit is
        refused with **422**. On success the permit holders are notified (work must
        stop, except the actor). The response re-reads the full permit detail (same
        eager-load set as the detail read — no N+1) with the refreshed viewer
        permissions, so the client repaints the detail — the `suspended` state and
        `suspended_reason` — without a second round trip.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:safety_hub` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional free-text reason recorded on `suspended_reason`.
                    Defaults to "Suspended by {name}" when blank.
      responses:
        '200':
          description: Permit suspended (permit is now `suspended`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the permit is at a site the manager
            does not manage, an own-scoped token (wide scope required), or Safety
            Hub / the Permits module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — the permit is not issued (only an issued/approved
            permit can be suspended)
  "/safety_hub/permits/{id}/resume":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Resume a suspended permit
      description: |
        Resume ONE suspended permit — the mockup's permit-detail "Resume" control,
        and the native twin of the desktop
        `Apps::SafetyHub::PermitsController#resume`. The **inverse of suspend**:
        flips a `suspended` permit back → `approved` (work may restart) and clears
        `suspended_reason`; the permit stays **open**.

        **Manager-only within accessible sites** — the desktop wires resume behind
        `authorize_safety_hub_manager!` + `authorize_permit_authoriser!`
        (authorising a permit is a manager act at the permit's site), the SAME
        chain approve and suspend use. A non-manager is refused, as is a
        site-scoped manager whose accessible sites exclude the permit. The wide
        `write:safety_hub` scope is required **unconditionally** — resuming is
        always a management action, so there is no own-scoped case. Gated on the
        tenant's `permits_enabled` module toggle. Offered to the client as
        `permissions.can_resume`, which is false for any caller or token this
        endpoint would refuse (a certificate gap leaves it true — see below).

        Only a **suspended** permit can be resumed; a
        `draft`/`requested`/`approved`/`closed`/`cancelled`/`expired` permit is
        refused with **422**. Resume fires **no notification** (matching the web).
        The response re-reads the full permit detail (same eager-load set as the
        detail read — no N+1) with the refreshed viewer permissions, so the client
        repaints the detail — the restored `approved` state and the now-cleared
        `suspended_reason` — without a second round trip.

        **Contractor certificate gaps (CTR-030).** Resuming puts a crew back on
        site, so while the contractor has `certificate_gaps` the call needs the
        same `proceed_anyway` + `certificate_override_reason` +
        `confirmed_gap_keys` as issuing. The confirmation is recorded against the
        RESUMER (`certificate_override.action: resumed`), even when the issuer
        had overridden the same gaps.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SafetyHubPermitCertificateConfirmation"
      responses:
        '200':
          description: Permit resumed (permit is now `approved`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the permit is at a site the manager
            does not manage, an own-scoped token (wide scope required), or Safety
            Hub / the Permits module disabled
        '404':
          description: Permit not found
        '422':
          description: 'Unprocessable — the permit is not suspended (only a suspended
            permit can be resumed), or the contractor has certificate gaps that were
            not all confirmed (then the body carries `confirmation_required: true`).'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SafetyHubPermitCertificateGapRefusal"
  "/safety_hub/permits/{id}/confirm_certificate_cover":
    post:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Record that a contractor's cover was confirmed
      description: |
        "Cover confirmed" (CTR-030) — the answer to the day-before
        contractor-certificate ask on an ISSUED permit, and the native twin of
        the desktop `Apps::SafetyHub::PermitsController#confirm_certificate_cover`.
        The authoriser checked with the contractor that cover is in place (for
        example a renewal emailed but not yet on file); the reason, the gaps
        confirmed, who and when are recorded on the permit
        (`certificate_override.action: cover_confirmed`, earlier confirmations
        kept) and the open day-before asks close.

        **Manager-only within accessible sites** — the same authoriser chain as
        approve / suspend / resume, and the wide `write:safety_hub` scope
        unconditionally. Offered to the client as
        `permissions.can_confirm_certificate_cover`. A permit that is not issued,
        or whose contractor has no certificate gaps, is a **422**.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - certificate_override_reason
              - confirmed_gap_keys
              properties:
                certificate_override_reason:
                  type: string
                  maxLength: 2000
                  description: How cover was confirmed. Required, non-blank.
                confirmed_gap_keys:
                  type: array
                  items:
                    type: string
                  description: 'The `key` of every gap you showed the user. A live
                    gap missing from this list is refused (`certificate_gaps_changed:
                    true`).'
      responses:
        '200':
          description: Cover confirmed and recorded on the permit
          content:
            application/json:
              schema:
                type: object
                properties:
                  permit:
                    "$ref": "#/components/schemas/SafetyHubPermitDetail"
                  permissions:
                    "$ref": "#/components/schemas/SafetyHubPermitPermissions"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — not a manager, the permit is at a site the manager
            does not manage, an own-scoped token (wide scope required), or Safety
            Hub / the Permits module disabled
        '404':
          description: Permit not found
        '422':
          description: Unprocessable — the permit is not issued, the contractor has
            no certificate gaps, or the reason / `confirmed_gap_keys` did not confirm
            every live gap.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SafetyHubPermitCertificateGapRefusal"
  "/safety_hub/knowledge_base":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Safety Knowledge Base (list, filter & search)
      description: |
        The Safety Knowledge Base — the native mirror of the desktop
        `Apps::SafetyHub::KnowledgeBaseController` and the mockup's "Knowledge
        Base" tab. Employee self-service content, so it is available to EVERY
        persona (no `team=` param).

        There is no personal/team split. Instead, exactly as the desktop
        `#base_kb_scope` does, a **manager** (manager-or-above OR the safety-hub
        app-admin) sees every status — including `draft`, `failed` and
        `archived` editorial content — while a **member** sees only `active`
        (published) articles. Global system safety content is included for
        both.

        Free-text search (`q`) is a token-AND ILIKE across title / question /
        answer / content (word order does not matter). Results are ordered by
        the model's display order, then most-recent, with an id tiebreak so a
        tie group cannot drop or duplicate a row across a page boundary.

        `categories` echoes the seven category chips, each with an un-paginated
        count (respecting the `source_type` filter but NOT the `category`
        filter, so a chip's badge and the page it opens describe the same set),
        plus an `all` total.

        Requires Safety Hub to be enabled for the business.
      parameters:
      - name: category
        in: query
        description: |
          Filter to one Safety Hub category. An unknown value returns 400
          (it is never silently matched). A malformed container shape is
          ignored (treated as no filter).
        schema:
          type: string
          enum:
          - emergency_procedures
          - hazard_recognition
          - ppe_guides
          - safe_work_procedures
          - incident_response
          - toolbox_talk_content
          - general_safety
      - name: source_type
        in: query
        description: |
          Filter by content type. An unknown value is ignored (treated as no
          filter).
        schema:
          type: string
          enum:
          - faq
          - document
          - url
          - video
      - name: q
        in: query
        description: Free-text search across title / question / answer / content (token-AND
          ILIKE).
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        description: Page size, clamped to [1, 100]. Defaults to 25.
        schema:
          type: integer
          default: 25
      responses:
        '200':
          description: A page of knowledge base articles
          content:
            application/json:
              schema:
                type: object
                properties:
                  knowledge_base:
                    type: array
                    items:
                      "$ref": "#/components/schemas/SafetyHubKnowledgeBaseEntry"
                  categories:
                    type: array
                    description: The category chips (an "all" total plus each of the
                      seven categories) with un-paginated counts.
                    items:
                      "$ref": "#/components/schemas/SafetyHubKbCategoryFacet"
                  pagination:
                    "$ref": "#/components/schemas/SafetyHubPaginationMeta"
                  filters:
                    type: object
                    description: Echo of the filters actually applied.
                    properties:
                      category:
                        type: string
                        nullable: true
                      source_type:
                        type: string
                        nullable: true
                      q:
                        type: string
                        nullable: true
        '400':
          description: Bad request — unknown category
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled or no access
  "/safety_hub/knowledge_base/{id}":
    get:
      tags:
      - Safety Hub
      security:
      - BearerAuth: []
      summary: Get one knowledge base article
      description: |
        Full article detail — title, category, status, the primary
        question/answer, any additional Q&A pairs (`faq_items`), the extracted
        body (`content`) for document/url/video entries, a safe external
        `source_url`, attached-file metadata, and the author + timestamps.

        Each Q&A pair appears ONCE: for an FAQ with authored pairs, the first
        pair is `question`/`answer` and `faq_items` holds only the pairs after
        it — render `question`/`answer`, then `faq_items`. An FAQ with no
        authored pairs returns `faq_items: []`.

        A member requesting a non-active article (draft / failed / archived)
        receives 404 — the same content boundary the list enforces; a manager
        may open any status.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Knowledge base article detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  knowledge_base_entry:
                    "$ref": "#/components/schemas/SafetyHubKnowledgeBaseEntryDetail"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Safety Hub disabled or no access
        '404':
          description: Article not found
  "/onboarding/my_plan":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Get the caller's onboarding plan (phases + items)
      description: |
        Returns the caller's active `RecruitingOnboardingPlan` (status in
        `active`, `preboarding`, or `draft`) bucketed into the six fixed
        phases. Each phase carries its own `items` array, per-phase
        `progress` percent, and `items_total`/`items_completed` counts.

        Item types covered: `task`, `form`, `document`, `checkpoint`,
        `survey`, `training`. The `id` field is a stable composite
        (`<type>_<record_id>`) — clients should treat it opaquely.

        Phase boundaries (relative to `plan.start_date`):
          * `preboarding` — day_offset < 0
          * `day_1`       — day_offset 0 (the start date)
          * `week_1`      — day_offset 1..7
          * `month_1`     — day_offset 8..30
          * `month_2_3`   — day_offset 31..90
          * `beyond`      — day_offset >= 91

        Items are sorted within each phase by `(day_offset ASC, name ASC)`.

        `days_overdue` is populated only when the item's `due_date` is in
        the past AND its status is NOT one of `completed`, `verified`, or
        `skipped` — matching the red "X days overdue" badge in the desktop
        view. Otherwise `null`.

        `overall_progress` is the new hire's own figure
        (`RecruitingOnboardingPlan#new_hire_progress` — the one the portal
        header, its My Progress tab and the phone show): every required item
        on the plan, and its required welcome items while the welcome packet
        is on, with partial credit (0.75) for documents in the `uploaded`
        state. Rounded to the nearest whole number, never 0 once anything
        counts and never 100 until everything is done, so it can diverge
        slightly from a naïve completed/total ratio over all phases.

        `progress_shown` is the tenant's "Progress Tracking" setting. When it
        is `false` the hire is shown no percentage and no days left on any
        surface, so `overall_progress`, `days_remaining` and each phase's
        `progress` are `null`; the item counts and items are still sent.

        **Required Scopes:** `read:onboarding` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; the scope Ask AI and MCP ask for the
        Onboarding agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:onboarding") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      responses:
        '200':
          description: The caller's onboarding plan with phases and items.
          content:
            application/json:
              schema:
                type: object
                required:
                - plan
                - phases
                properties:
                  plan:
                    type: object
                    required:
                    - id
                    - status
                    - progress_shown
                    - overall_progress
                    properties:
                      id:
                        type: integer
                        example: 42
                      status:
                        type: string
                        enum:
                        - active
                        - preboarding
                        - draft
                        example: preboarding
                      start_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-04-25'
                      target_completion_date:
                        type: string
                        format: date
                        nullable: true
                        example: '2026-07-24'
                      progress_shown:
                        type: boolean
                        description: |
                          Whether the hire is shown their progress (the
                          "Progress Tracking" setting). `false` nulls
                          `overall_progress`, `days_remaining` and each phase's
                          `progress`.
                        example: true
                      days_remaining:
                        type: integer
                        nullable: true
                        description: Null when `progress_shown` is false.
                        example: 39
                      overall_progress:
                        type: integer
                        nullable: true
                        description: |
                          Plan-wide completion percent (0-100), the figure
                          the hire's portal shows
                          (RecruitingOnboardingPlan#new_hire_progress),
                          including partial credit for uploaded-but-unverified
                          documents; 100 only once every required item is done.
                          Null when `progress_shown` is false.
                        example: 29
                  phases:
                    type: array
                    description: |
                      Always six entries, in fixed order. Empty phases
                      are returned with `items: []` and `progress: 0` (null
                      when `progress_shown` is false) so the client can
                      render every row unconditionally.
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - progress
                      - items_total
                      - items_completed
                      - items
                      properties:
                        key:
                          type: string
                          enum:
                          - preboarding
                          - day_1
                          - week_1
                          - month_1
                          - month_2_3
                          - beyond
                          example: preboarding
                        label:
                          type: string
                          example: Pre-boarding
                        description:
                          type: string
                          example: Before the start date
                        progress:
                          type: integer
                          nullable: true
                          description: Per-phase completion percent (0-100). Null
                            when `progress_shown` is false.
                          example: 60
                        items_total:
                          type: integer
                          example: 5
                        items_completed:
                          type: integer
                          example: 3
                        items:
                          type: array
                          items:
                            type: object
                            required:
                            - id
                            - type
                            - title
                            - status
                            properties:
                              id:
                                type: string
                                description: Stable composite (`<type>_<record_id>`).
                                example: document_19
                              type:
                                type: string
                                enum:
                                - task
                                - form
                                - document
                                - checkpoint
                                - survey
                                - training
                                example: document
                              title:
                                type: string
                                example: Upload BOSIET / OPITO Survival Certificate
                              due_date:
                                type: string
                                format: date
                                nullable: true
                                example: '2026-05-01'
                              status:
                                type: string
                                description: |
                                  Raw status from the underlying record (with the
                                  task model's `display_status` collapse applied
                                  for tasks). The full set:
                                  `pending`, `in_progress`, `submitted`, `scheduled`,
                                  `completed`, `verified`, `uploaded`, `rejected`,
                                  `overdue`, `skipped`.
                                example: pending
                              status_label:
                                type: string
                                description: Humanized form of `status` (e.g. "In
                                  progress").
                                example: Pending
                              status_color:
                                type: string
                                description: |
                                  Bootstrap contextual color token for
                                  `status`, identical to the desktop timeline
                                  badge. Prefix with `bg-`/`text-` to render.
                                enum:
                                - success
                                - info
                                - warning
                                - danger
                                - secondary
                                example: warning
                              icon:
                                type: string
                                description: FontAwesome icon class for the item type.
                                example: fas fa-tasks
                              icon_color:
                                type: string
                                description: |
                                  Bootstrap contextual color token for the
                                  item-type `icon`.
                                example: success
                              required:
                                type: boolean
                                example: true
                              assignee:
                                type: string
                                nullable: true
                                description: Display name of the assignee/owner (tasks
                                  + checkpoints only).
                                example:
                              days_overdue:
                                type: integer
                                nullable: true
                                description: |
                                  Days past due. `null` for items that are not
                                  overdue OR are in a terminal state
                                  (`completed` / `verified` / `skipped`).
                                example: 43
                              web_url:
                                type: string
                                format: uri
                                description: |
                                  Absolute URL of the item's detail page in the
                                  desktop Onboarding Hub — the same link the
                                  desktop timeline renders. Surveys and
                                  trainings have no standalone detail page, so
                                  they point at the plan page.
                                example: https://officechat-dev.workforce.mangoapps.com/apps/onboarding_hub/documents/19
                              mobile_url:
                                type: string
                                format: uri
                                description: |
                                  Absolute URL of the item's detail page on the
                                  mobile-web surface (`/m/apps/onboarding-hub/...`).
                                  Only task & document ship a dedicated mobile
                                  detail page; every other type falls back to
                                  the mobile plan page so the link always
                                  resolves.
                                example: https://officechat-dev.workforce.mangoapps.com/m/apps/onboarding-hub/documents/19
        '401':
          description: Authentication required.
        '403':
          description: The token lacks `read:onboarding` (`insufficient_permissions`),
            or Onboarding Hub is not enabled for the business (`app_not_enabled`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: The caller has no active onboarding plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: no_active_plan
                      message:
                        type: string
                        example: You do not have an active onboarding plan.
  "/onboarding/team":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Team Onboarding summary (overlapping bucket counts)
      description: |
        Aggregates onboarding items across the active plans on the caller's
        desktop plans list (their purview; a people leader on the employee
        role also gets their team's plans) — status one of `active`,
        `preboarding`, `draft`. Returns seven
        **overlapping** counts the mobile widget renders:

          - Six PHASE buckets (`preboarding`, `day_1`, `week_1`,
            `month_1`, `month_2_3`, `beyond`) — each item is counted in
            exactly one phase bucket. The six phase counts sum to
            `total_items`.
          - One OVERDUE bucket — cross-phase count of every item past
            its due date (`TimelinePhasesService.item_overdue?`).
            **Overdue items are ALSO counted in their phase bucket**,
            so the seven buckets do NOT sum to `total_items` — overdue
            is an overlapping subset, not a separate slice.

        Cross-endpoint invariants:

          - `/team` `buckets[phase_X].count` ≡ `/team/items?phase=X`
            `meta.total_count`
          - `/team` `buckets[overdue].count` ≡ `/team/items?status=overdue`
            `meta.total_count`

        Always returns HTTP 200, even when the caller has zero team plans
        — all `buckets[].count` are 0 and `team.plan_count` is 0. The
        widget hides itself client-side when `team.plan_count == 0`.

        **Required Scopes:** `read:onboarding` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; the scope Ask AI and MCP ask for the
        Onboarding agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:onboarding") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      responses:
        '200':
          description: Aggregated team onboarding counts.
          content:
            application/json:
              schema:
                type: object
                required:
                - team
                - buckets
                - total_items
                properties:
                  team:
                    type: object
                    required:
                    - plan_count
                    - member_count
                    properties:
                      plan_count:
                        type: integer
                        description: Number of active plans the caller manages.
                        example: 4
                      member_count:
                        type: integer
                        description: |
                          Distinct new-hires across those plans. Differs
                          from `plan_count` only in the rare case where a
                          single person has more than one active plan.
                        example: 4
                  buckets:
                    type: array
                    description: |
                      Seven entries in fixed order: `overdue` first, then
                      the six phases chronologically. The overdue bucket
                      OVERLAPS with the phase buckets (an overdue day_1
                      item counts in BOTH `overdue` AND `day_1`). Empty
                      buckets are still present with `count: 0` so the
                      client can render every legend row unconditionally.
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - count
                      properties:
                        key:
                          type: string
                          enum:
                          - overdue
                          - preboarding
                          - day_1
                          - week_1
                          - month_1
                          - month_2_3
                          - beyond
                          example: overdue
                        label:
                          type: string
                          example: Overdue
                        count:
                          type: integer
                          example: 14
                  total_items:
                    type: integer
                    description: |
                      Distinct count of items across the team. Equal to
                      the sum of the six PHASE bucket counts (each item is
                      in exactly one phase). The `overdue` bucket is a
                      subset and does NOT contribute to this sum.
                    example: 62
        '401':
          description: Authentication required.
        '403':
          description: 'The token lacks `read:onboarding` (`insufficient_permissions`),
            Onboarding Hub is not enabled for the business (`app_not_enabled`), or
            the caller may not open the team plans list (`forbidden`): not an administrator,
            manager, Onboarding Hub administrator, HR coordinator or people leader.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/onboarding/team/items":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Team Onboarding drill-in (paginated items, phase + status filters)
      description: |
        Returns the line items in a manager's team plans, filtered by
        two independent axes — `phase` and `status` — plus optional
        free-text search. Powers the mobile widget's drill-in modal
        when a manager taps a pie slice or changes the dropdowns inside
        the modal.

        Filters compose. `phase=day_1&status=overdue` returns day_1
        items that are also past due. `phase=all&status=overdue` (the
        common "tap Overdue slice" path) returns every overdue item
        across all phases, matching the screenshot's "Phase: Any,
        Status: Overdue" view.

        Status partition (canonical 3-way collapse via
        `TimelinePhasesService.item_status_group`):

          * `overdue`   — item past `due_date` and not in a terminal
                          state (same predicate as the pie chart's
                          overdue bucket)
          * `completed` — item status ∈ {completed, verified}
          * `pending`   — everything else (pending, in_progress,
                          uploaded, submitted, scheduled, rejected,
                          skipped)

        Each row carries the originating `phase` AND the row's
        `status_group` regardless of how the filter was applied, so the
        client can render the Phase / Status columns the screenshot
        shows even when filtering on one axis.

        The `responsible` block is the new hire being onboarded
        (`plan.user` or `plan.recruiting_candidate`) — NOT the task's
        `assigned_to`. The latter, when set, is surfaced separately as
        `assignee` for tasks/checkpoints.

        A people leader on the employee role (reports here, no manager role)
        is admitted, as on the desktop plans list, and sees their team's
        items. Rows from plans they may see but not open carry
        `openable: false` with `web_url` and `mobile_url` null — the client
        draws them without a link, as the desktop list does.

        **Required Scopes:** `read:onboarding` (#7183, owner rule 2026-10-06
        "every api/v1 door asks a scope"; the scope Ask AI and MCP ask for the
        Onboarding agent). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:onboarding") before anything else is checked. The
        sign-in tokens hold it; a browser session is asked no scope.
      parameters:
      - name: phase
        in: query
        description: |
          Phase filter. One of the six standard phase keys, or `all`
          to skip phase filtering.
        schema:
          type: string
          enum:
          - preboarding
          - day_1
          - week_1
          - month_1
          - month_2_3
          - beyond
          - all
          default: all
      - name: status
        in: query
        description: |
          Status filter — the canonical 3-way collapse. `all` skips
          status filtering.
        schema:
          type: string
          enum:
          - overdue
          - completed
          - pending
          - all
          default: all
      - name: search
        in: query
        description: Case-insensitive substring filter against item title and responsible
          name.
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated items matching the supplied filters.
          content:
            application/json:
              schema:
                type: object
                required:
                - filters
                - items
                - meta
                properties:
                  filters:
                    type: object
                    description: Echo of the applied filters so the client can re-render
                      the dropdowns.
                    properties:
                      phase:
                        type: object
                        properties:
                          key:
                            type: string
                            enum:
                            - preboarding
                            - day_1
                            - week_1
                            - month_1
                            - month_2_3
                            - beyond
                            - all
                            example: all
                          label:
                            type: string
                            example: All phases
                      status:
                        type: object
                        properties:
                          key:
                            type: string
                            enum:
                            - overdue
                            - completed
                            - pending
                            - all
                            example: overdue
                          label:
                            type: string
                            example: Overdue
                  items:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - type
                      - title
                      - status
                      - phase
                      - due_date
                      - responsible
                      - plan_id
                      - openable
                      - web_url
                      - mobile_url
                      properties:
                        id:
                          type: string
                          description: Stable composite (`<type>_<record_id>`).
                          example: training_42
                        type:
                          type: string
                          enum:
                          - task
                          - form
                          - document
                          - checkpoint
                          - survey
                          - training
                          example: training
                        title:
                          type: string
                          example: Attend Aberdeen Mobilisation Briefing
                        status:
                          type: string
                          description: |
                            Raw status (with the task model's
                            `display_status` collapse applied for tasks).
                          example: pending
                        status_label:
                          type: string
                          example: Pending
                        status_group:
                          type: string
                          description: |
                            Canonical 3-way classification of the item's
                            current state (overdue/completed/pending) —
                            same value the `status` filter accepts.
                          enum:
                          - overdue
                          - completed
                          - pending
                          example: overdue
                        status_color:
                          type: string
                          description: |
                            Bootstrap contextual color token for `status`,
                            identical to the desktop timeline badge. Prefix
                            with `bg-`/`text-` to render.
                          enum:
                          - success
                          - info
                          - warning
                          - danger
                          - secondary
                          example: warning
                        icon:
                          type: string
                          description: FontAwesome icon class for the item type.
                          example: fas fa-tasks
                        icon_color:
                          type: string
                          description: |
                            Bootstrap contextual color token for the
                            item-type `icon`.
                          example: success
                        required:
                          type: boolean
                          example: true
                        phase:
                          type: object
                          description: |
                            Original phase the item belongs to — distinct
                            from the bucket. An item in the `overdue`
                            bucket still reports its source phase here.
                          properties:
                            key:
                              type: string
                              enum:
                              - preboarding
                              - day_1
                              - week_1
                              - month_1
                              - month_2_3
                              - beyond
                              example: day_1
                            label:
                              type: string
                              example: Day 1
                        due_date:
                          type: string
                          format: date
                          nullable: true
                          example: '2026-04-15'
                        days_overdue:
                          type: integer
                          nullable: true
                          example: 59
                        assignee:
                          type: string
                          nullable: true
                          description: Task/checkpoint assignee display name; nil
                            for forms, documents, surveys, trainings.
                          example:
                        responsible:
                          type: object
                          nullable: true
                          description: |
                            The new hire being onboarded
                            (`plan.user || plan.recruiting_candidate`).
                            Nil only when both are absent on a draft plan.
                          properties:
                            user_id:
                              type: integer
                              nullable: true
                              example: 42
                            candidate_id:
                              type: integer
                              nullable: true
                              example:
                            name:
                              type: string
                              example: Aldin Cumpton
                            initials:
                              type: string
                              example: AC
                            avatar_url:
                              type: string
                              format: uri
                              nullable: true
                              example: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/...
                        plan_id:
                          type: integer
                          example: 102
                        openable:
                          type: boolean
                          description: |
                            `false` for a row the caller may see but not open
                            (a people leader's team plans); `web_url` and
                            `mobile_url` are then null. `true` for every other
                            caller's rows.
                          example: true
                        web_url:
                          type: string
                          format: uri
                          nullable: true
                          description: |
                            Absolute URL of the item's detail page in the
                            desktop Onboarding Hub — the same link the
                            desktop timeline renders. Surveys and trainings
                            have no standalone detail page, so they point at
                            the plan page.
                          example: https://officechat-dev.workforce.mangoapps.com/apps/onboarding_hub/tasks/42
                        mobile_url:
                          type: string
                          format: uri
                          nullable: true
                          description: |
                            Absolute URL of the item's detail page on the
                            mobile-web surface (`/m/apps/onboarding-hub/...`).
                            Only task & document ship a dedicated mobile
                            detail page; every other type falls back to the
                            mobile plan page so the link always resolves.
                          example: https://officechat-dev.workforce.mangoapps.com/m/apps/onboarding-hub/tasks/42
                  meta:
                    type: object
                    properties:
                      page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 2
                      total_count:
                        type: integer
                        example: 30
                      phase:
                        type: string
                        example: all
                      status:
                        type: string
                        example: overdue
                      search:
                        type: string
                        nullable: true
                        example:
        '400':
          description: Unknown phase or status value.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: "`invalid_phase` or `invalid_status` depending
                          on which filter was bad."
                        example: invalid_status
                      message:
                        type: string
                        example: 'Unknown status: foo'
                      details:
                        type: object
                        properties:
                          allowed:
                            type: array
                            items:
                              type: string
        '401':
          description: Authentication required.
        '403':
          description: 'The token lacks `read:onboarding` (`insufficient_permissions`),
            Onboarding Hub is not enabled for the business (`app_not_enabled`), or
            the caller may not open the team plans list (`forbidden`): not an administrator,
            manager, Onboarding Hub administrator, HR coordinator or people leader.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/onboarding/plans/{id}":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Read one onboarding plan (admin)
      description: |
        The state of one onboarding plan, for a system that follows the
        `onboarding_plan.completed` and `onboarding_plan.reopened` webhook
        events (their `read_path` is this
        endpoint). State, ids and item counts by status — no answers.

        **Who:** the people who manage every plan: account administrators,
        Onboarding Hub administrators and HR coordinators. Anyone else gets 403.

        **Required Scopes:** `read:onboarding`
      parameters:
      - name: id
        in: path
        required: true
        description: Onboarding plan ID
        schema:
          type: integer
      responses:
        '200':
          description: The plan
          content:
            application/json:
              schema:
                type: object
                required:
                - plan
                properties:
                  plan:
                    type: object
                    required:
                    - id
                    - status
                    - items
                    properties:
                      id:
                        type: integer
                      user_id:
                        type: integer
                        nullable: true
                        description: The new hire. Null for a pre-hire with no account
                          yet.
                      status:
                        type: string
                      plan_name:
                        type: string
                        nullable: true
                      plan_type:
                        type: string
                        nullable: true
                      job_title:
                        type: string
                        nullable: true
                      start_date:
                        type: string
                        format: date
                        nullable: true
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                      completion_percentage:
                        type: number
                      hiring_manager_id:
                        type: integer
                        nullable: true
                      items:
                        type: object
                        description: For tasks, forms and documents, how many items
                          are in each status.
                        properties:
                          tasks:
                            type: object
                            additionalProperties:
                              type: integer
                          forms:
                            type: object
                            additionalProperties:
                              type: integer
                          documents:
                            type: object
                            additionalProperties:
                              type: integer
        '403':
          description: The token lacks `read:onboarding`, Onboarding Hub is not enabled,
            or the caller does not manage every plan
        '404':
          description: No such plan in this business
  "/onboarding/items/{id}":
    get:
      tags:
      - Onboarding
      security:
      - BearerAuth: []
      summary: Read one onboarding form item (admin)
      description: |
        The state of one form item on an onboarding plan, for a system that
        follows the `onboarding_item.verified` webhook event (its `read_path` is
        this endpoint). State and ids only — never an answer. The profile changes
        the submission filed are listed in `change_request_ids`; read each at
        `GET /api/v1/people/changes/{id}` (`read:people_changes`).

        **Who:** the people who manage every plan: account administrators,
        Onboarding Hub administrators and HR coordinators. Anyone else gets 403.

        **Required Scopes:** `read:onboarding`
      parameters:
      - name: id
        in: path
        required: true
        description: Onboarding form item ID (`enrollment_form_id` in the event)
        schema:
          type: integer
      responses:
        '200':
          description: The item
          content:
            application/json:
              schema:
                type: object
                required:
                - item
                properties:
                  item:
                    type: object
                    required:
                    - id
                    - plan_id
                    - status
                    - requires_hr_verification
                    - change_request_ids
                    properties:
                      id:
                        type: integer
                      plan_id:
                        type: integer
                        nullable: true
                        description: 'null for an item no onboarding plan holds: a
                          library form, or an I-9 Section 3 reverification held on
                          the employee (`user_id` names them).'
                      user_id:
                        type: integer
                        nullable: true
                        description: 'The person the item is about: the plan''s employee,
                          or the employee a Section 3 held on them reverifies.'
                      name:
                        type: string
                      form_type:
                        type: string
                      status:
                        type: string
                        enum:
                        - pending
                        - in_progress
                        - submitted
                        - completed
                        - skipped
                        description: "`submitted` means sent by the hire and waiting
                          for HR to verify."
                      due_date:
                        type: string
                        format: date
                        nullable: true
                      requires_hr_verification:
                        type: boolean
                      submitted_at:
                        type: string
                        format: date-time
                        nullable: true
                      verified_at:
                        type: string
                        format: date-time
                        nullable: true
                      verified_by_id:
                        type: integer
                        nullable: true
                      completed_at:
                        type: string
                        format: date-time
                        nullable: true
                      form_submission_id:
                        type: integer
                        nullable: true
                        description: The Forms submission behind a Forms-backed item.
                      change_request_ids:
                        type: array
                        items:
                          type: integer
        '403':
          description: The token lacks `read:onboarding`, Onboarding Hub is not enabled,
            or the caller does not manage every plan
        '404':
          description: No such item in this business
  "/offboarding/plans":
    get:
      tags:
      - Offboarding
      security:
      - BearerAuth: []
      summary: List offboarding plans
      description: |
        The offboarding plans the caller may see, newest first (`created_at`
        descending). Reach is the desktop's (`Offboarding::PlanAccess`): an
        admin, an Offboarding app admin or HR sees every plan in the
        organization; a manager sees only the plans of their own reports. Nobody
        sees a plan about themselves while it is still a draft.

        Each row is the plan's summary shape — the plan's own
        `GET /offboarding/plans/{id}` adds the termination details, the
        successor, the creator and the task and document counts.

        Filters, all optional and combined with AND:
        - `status` — one or more of `draft`, `active`, `completed`, `cancelled`,
          comma-separated (`?status=draft,active` returns either).
        - `plan_type` — one of `voluntary`, `involuntary`, `retirement`,
          `end_of_contract`, `layoff`.
        - `user_id` / `manager_id` — one id, or a list of ids
          (`?user_id[]=1&user_id[]=2`). They only narrow what the caller may
          already see.

        **An unusable filter is refused, never ignored:** an unknown `status`
        or `plan_type` value, or an id filter sent as an object
        (`?user_id[a]=1`), answers `400 invalid_filter` naming the accepted
        values.

        **Required Scopes:** `read:offboarding` (or `read:own_offboarding`, or
        `admin`). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:offboarding"), checked after the app and
        manage-offboarding gates. A browser or WebView session is asked no
        scope.
      parameters:
      - name: status
        in: query
        required: false
        description: Comma-separated plan statuses. Any value outside `draft`, `active`,
          `completed`, `cancelled` is a 400 `invalid_filter`.
        schema:
          type: string
          example: draft,active
      - name: plan_type
        in: query
        required: false
        description: Any other value is a 400 `invalid_filter`.
        schema:
          type: string
          enum:
          - voluntary
          - involuntary
          - retirement
          - end_of_contract
          - layoff
      - name: user_id
        in: query
        required: false
        description: The departing employee. One id, or a list (`user_id[]=1&user_id[]=2`);
          an object-shaped value is a 400 `invalid_filter`.
        schema:
          oneOf:
          - type: integer
          - type: array
            items:
              type: integer
      - name: manager_id
        in: query
        required: false
        description: The plan's manager. One id, or a list (`manager_id[]=1&manager_id[]=2`);
          an object-shaped value is a 400 `invalid_filter`.
        schema:
          oneOf:
          - type: integer
          - type: array
            items:
              type: integer
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Missing or non-positive means 25; above 100 is
          clamped to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: One page of the plans the caller may see, newest first.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/OffboardingPlanSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: |
            `invalid_filter` — an unknown `status` or `plan_type` value, or a
            `user_id` / `manager_id` sent as an object. The message names the
            accepted values.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `offboarding_not_enabled` — the Offboarding app is not enabled for
              this organization.
            - `insufficient_permissions` — the app is on but not open to the caller
              ("You do not have access to Offboarding"), the caller may not manage
              offboarding ("You do not have access to offboarding management features"),
              or the token lacks the scope ("Required scope: read:offboarding").
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/offboarding/tasks":
    get:
      tags:
      - Offboarding
      security:
      - BearerAuth: []
      summary: List one offboarding plan's tasks
      description: |
        The checklist tasks of one offboarding plan, named by the required
        `plan_id`, in the plan's sequence (`sequence_order`, then creation
        order, so paging never repeats or skips a task).

        The caller must be able to see that plan — the same reach as
        `GET /offboarding/plans` (an admin, an Offboarding app admin or HR for
        any plan; a manager for their own reports' plans) — or the list is
        refused with `403 unauthorized`. Each row is the task's summary shape;
        `GET /offboarding/tasks/{id}` adds the description, timing and
        completion details.

        Filters, optional and combined with AND:
        - `status` — one or more of `pending`, `in_progress`, `completed`,
          `skipped`, `blocked`, comma-separated.
        - `department` — one of `IT`, `HR`, `Facilities`, `Payroll`, `Manager`,
          `Legal`, `Finance`, `Operations` (case-sensitive).

        An unknown `status` or `department` value answers `400 invalid_filter`
        naming the accepted values, never an empty list.

        **Required Scopes:** `read:offboarding` (or `read:own_offboarding`, or
        `admin`). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:offboarding"), checked after the app and
        manage-offboarding gates. A browser or WebView session is asked no
        scope.
      parameters:
      - name: plan_id
        in: query
        required: true
        description: The offboarding plan whose tasks to list.
        schema:
          type: integer
      - name: status
        in: query
        required: false
        description: Comma-separated task statuses. Any value outside `pending`, `in_progress`,
          `completed`, `skipped`, `blocked` is a 400 `invalid_filter`.
        schema:
          type: string
          example: pending,in_progress
      - name: department
        in: query
        required: false
        description: Case-sensitive. Any other value is a 400 `invalid_filter`.
        schema:
          type: string
          enum:
          - IT
          - HR
          - Facilities
          - Payroll
          - Manager
          - Legal
          - Finance
          - Operations
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Missing or non-positive means 25; above 100 is
          clamped to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: One page of the plan's tasks, in sequence.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/OffboardingTaskSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: |
            - `missing_parameter` — `plan_id` was not sent ("plan_id is required").
            - `invalid_filter` — an unknown `status` or `department` value; the
              message names the accepted values.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `offboarding_not_enabled` — the Offboarding app is not enabled for
              this organization.
            - `insufficient_permissions` — the app is on but not open to the caller
              ("You do not have access to Offboarding"), the caller may not manage
              offboarding, or the token lacks the scope ("Required scope: read:offboarding").
            - `unauthorized` — the plan exists but is outside the caller's reach
              (for a manager, someone who is not their report).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`plan_not_found` — no plan with that `plan_id` in this organization."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/offboarding/documents":
    get:
      tags:
      - Offboarding
      security:
      - BearerAuth: []
      summary: List one offboarding plan's documents
      description: |
        The documents of one offboarding plan (separation agreement, final pay
        stub, return checklist and the rest), named by the required `plan_id`,
        in the plan's sequence (`sequence_order`, then creation order).

        The caller must be able to see that plan — the same reach as
        `GET /offboarding/plans` (an admin, an Offboarding app admin or HR for
        any plan; a manager for their own reports' plans) — or the list is
        refused with `403 unauthorized`. Each row is the document's summary
        shape; `GET /offboarding/documents/{id}` adds the file, verification
        and e-signature details.

        Filters, optional and combined with AND:
        - `status` — one or more of `pending`, `uploaded`, `verified`,
          `rejected`, comma-separated.
        - `document_type` — one document type (see the parameter).

        An unknown `status` or `document_type` value answers
        `400 invalid_filter` naming the accepted values, never an empty list.

        **Required Scopes:** `read:offboarding` (or `read:own_offboarding`, or
        `admin`). A token without it gets `403 insufficient_permissions`
        ("Required scope: read:offboarding"), checked after the app and
        manage-offboarding gates. A browser or WebView session is asked no
        scope.
      parameters:
      - name: plan_id
        in: query
        required: true
        description: The offboarding plan whose documents to list.
        schema:
          type: integer
      - name: status
        in: query
        required: false
        description: Comma-separated document statuses. Any value outside `pending`,
          `uploaded`, `verified`, `rejected` is a 400 `invalid_filter`.
        schema:
          type: string
          example: pending,uploaded
      - name: document_type
        in: query
        required: false
        description: Any other value is a 400 `invalid_filter`.
        schema:
          type: string
          enum:
          - separation_agreement
          - nda_reminder
          - return_checklist
          - exit_interview
          - cobra_notice
          - final_pay_stub
          - benefits_summary
          - exit_interview_form
          - nda_acknowledgment
          - benefits_information
          - final_paycheck
          - equipment_return
          - reference_letter
          - custom
          - other
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Rows per page. Missing or non-positive means 25; above 100 is
          clamped to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: One page of the plan's documents, in sequence.
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/OffboardingDocumentSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: |
            - `missing_parameter` — `plan_id` was not sent ("plan_id is required").
            - `invalid_filter` — an unknown `status` or `document_type` value;
              the message names the accepted values.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `offboarding_not_enabled` — the Offboarding app is not enabled for
              this organization.
            - `insufficient_permissions` — the app is on but not open to the caller
              ("You do not have access to Offboarding"), the caller may not manage
              offboarding, or the token lacks the scope ("Required scope: read:offboarding").
            - `unauthorized` — the plan exists but is outside the caller's reach
              (for a manager, someone who is not their report).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`plan_not_found` — no plan with that `plan_id` in this organization."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/offboarding/plans/{id}/cancel":
    patch:
      tags:
      - Offboarding
      security:
      - BearerAuth: []
      summary: Cancel an offboarding plan
      description: |
        Cancels the plan and notifies the people the plan's notification
        settings name. A plan that is already cancelled answers 200 with the
        plan and "Offboarding plan was already cancelled", and notifies nobody.

        Delete-level (owner decision 2026-10-04): requires the `destructive:offboarding`
        scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The offboarding plan.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why the plan was cancelled; kept in the plan's termination
                    notes.
      responses:
        '200':
          description: Cancelled (or already cancelled). Returns the plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:
                    type: object
                    description: The plan, in the detailed shape the plan's own GET
                      returns.
                  message:
                    type: string
        '403':
          description: |
            The token lacks `destructive:offboarding` (error code `insufficient_permissions`,
            "Required scope: destructive:offboarding"), the app is not enabled, or the caller
            may not modify this plan.
        '404':
          description: No such plan in this organization (error code `plan_not_found`).
        '422':
          description: The plan could not be cancelled.
  "/offboarding/plans/{id}":
    delete:
      tags:
      - Offboarding
      security:
      - BearerAuth: []
      summary: Delete an offboarding plan
      description: |
        Deletes the plan. Answers 200 with the deleted plan's id.

        Delete-level (owner decision 2026-10-04): requires the `destructive:offboarding`
        scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The offboarding plan.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Offboarding plan deleted successfully
                  id:
                    type: integer
        '403':
          description: |
            The token lacks `destructive:offboarding` (error code `insufficient_permissions`),
            the app is not enabled, or the caller may not modify this plan.
        '404':
          description: No such plan in this organization (error code `plan_not_found`).
        '409':
          description: Other records still point at the plan (error code `plan_has_dependent_records`).
        '422':
          description: The plan could not be deleted.
  "/feeds":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List feed posts (filtered, paginated)
      description: |
        Returns the audience-scoped, published, non-expired feed posts the
        caller can see. Mirrors the web's tabbed feed (PRD 02 §5.1) — same
        visibility, same ordering, same filter set.

        Default ordering: applicable editorial features first, then unread posts first, then within each
        unread/read bucket by priority (`must_read` → `operational` →
        `social`) and finally `published_at DESC`. The `pinned` filter
        overrides this with the caller's own `pinned_at DESC` (per-user);
        `scheduled` overrides with `scheduled_at ASC` (soonest first).
        `posted=<id>` puts the caller's own just-published post ahead of all
        of these (see the parameter).

        When `filter=all`, the response includes a `meta.unread_counts`
        object with per-scope unread totals (`all`, `must_read`,
        `company_news`, `operational`, `pinned`, `social`, `mentions`) so the client can
        render nav badges without extra round-trips. Each bucket reuses
        the same audience-scoped, published+active visibility constraints
        as the index list.

        The `filter` and the legacy `category` / `must_read` / `segment_id`
        params compose. `topic_id` is independent and stacks with any filter.

        `filter=must_read&unacked=1` is "still waiting on you" — the web Must
        Read tab's `?unacked=1`: only what the caller still owes an
        acknowledgement on (the must-reads still asking them and the posts of
        the broadcasts they owe), published and unexpired, ordered by the
        acknowledge-by deadline (`due_at`, else `expires_at`; undated last),
        then `published_at DESC`. Read live: a post leaves the list on the next
        call once acknowledged. `meta.counts.must_read_unacked` counts it.

        `q` searches the posts instead (the desktop and phone "Search posts",
        `NewsFeed::PostSearch`): published, unexpired posts already in the
        caller's audience whose text matches every word, newest first, paged
        by `page` / `per_page`. A search ignores `filter`, `topic_id`,
        `category`, `segment_id`, `must_read`, `unacked` and `as_of`; its
        `meta` is the pagination plus `query` (the trimmed `q`). A blank `q`
        is the normal list; one character answers 422 `query_too_short`.
      parameters:
      - name: q
        in: query
        required: false
        description: Search the posts' text (at least 2 characters). See above.
        schema:
          type: string
          minLength: 2
          example: fire drill
      - name: filter
        in: query
        description: |
          Primary filter. Mutually exclusive set; default `all`.
            * `all`         — every visible published post
            * `pinned`      — posts the calling user has personally pinned
                              (per-user — does not include posts pinned by
                              other users). Ordered by the caller's
                              `pinned_at DESC`.
            * `mentions`    — posts where the caller was @-mentioned in any
                              comment on the post. Backed by the
                              `news_feed_mention` notifications index — only
                              comment mentions create notifications, so
                              body-only mentions are NOT included here.
            * `unread`      — visible posts that are unread for the caller RIGHT
                              NOW (no read record, or unread again because of
                              new comments / an owed must-read acknowledgement
                              — the same predicate as `viewer.read == false`
                              and `meta.unread_counts`). A post read since
                              `as_of` was stamped is dropped from the page, so
                              this list never contains a row whose own
                              `viewer.read` is `true`.
            * `must_read`   — priority='must_read'
            * `company_news` — content_category='company_news' (a CATEGORY: these
                              posts also carry an operational or must_read priority)
            * `operational` — priority='operational'
            * `social`      — priority='social'
            * `scheduled`   — caller's OWN posts queued for future publish
                              (status='scheduled'). Bypasses the default
                              published-only filter; ordered by
                              `scheduled_at ASC` (soonest first).
        schema:
          type: string
          enum:
          - all
          - pinned
          - mentions
          - unread
          - must_read
          - company_news
          - operational
          - social
          - scheduled
          default: all
      - name: topic_id
        in: query
        description: Restrict to posts assigned this topic (author or AI source).
          A post also matches when the topic lives on one of its active comments.
        schema:
          type: integer
      - name: category
        in: query
        description: Legacy filter by content_category. Composes with `filter`.
        schema:
          type: string
          enum:
          - operational
          - company_news
          - social
      - name: segment_id
        in: query
        description: Legacy filter — posts targeted at the given audience segment.
        schema:
          type: string
      - name: must_read
        in: query
        description: Legacy boolean filter. Maps to `filter=must_read` if `filter`
          is omitted.
        schema:
          type: boolean
      - name: unacked
        in: query
        description: |
          With `filter=must_read` only (ignored on every other filter):
          `1`/`true` narrows the list to what the caller still has to
          acknowledge — the same rule as the web Must Read tab's
          "still waiting on you", Home's Needs Acknowledgement and
          `GET /comms_hub/dashboard`'s `counts.pending_acknowledgements`.
          Includes the post of a broadcast the caller still owes, and a
          must-read sent to them before they moved out of its audience
          (read-only: `engageable` false, `acknowledgement_prompt` kept).
        schema:
          type: boolean
          default: false
      - name: posted
        in: query
        required: false
        description: |
          The id of the post the caller just published (the web Feed's
          `?posted=` after Compose). When it names a published post the
          caller wrote in this business, that post is listed first on page 1:
          their own post is read for them the moment it publishes, so the
          unread-first order would otherwise put it below every unread post.
          Send it on every page of the session — each page then sorts by the
          same keys and the post is listed once, never again on page 2+.
          It only reorders: someone else's post, a draft, a scheduled post or
          an unknown id leaves the order unchanged (no error), and a post
          outside the list (another `filter`, `topic_id`) is not added.
          Ignored by `filter=pinned`, `filter=scheduled`, `unacked=1` and `q`.
        schema:
          type: integer
          minimum: 1
          example: 123
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      - name: as_of
        in: query
        description: |
          Optional ISO-8601 read-state cutoff for the paging session. The
          unread predicate is the primary sort key of an OFFSET-paginated
          list, so marking cards read while paging otherwise re-sorts the
          list under the client's own offsets and page N+1 skips rows that
          slid up. Send back the `meta.as_of` value the first page returned
          and every page is ordered against the same instant.

          Safe to omit: the server holds the snapshot for a short paging
          session per caller and per `filter`, so `page >= 2` reuses the window
          page 1 was ordered against even when the client sends nothing, and a
          request on another `filter` cannot move it. Requesting page 1 (or
          omitting `page`) always re-stamps that filter's snapshot. Values that
          are blank, unparseable, in the future, or older than 12 hours fall
          back to that server-held snapshot (never an error).

          ORDERING ONLY. It does NOT decide what you are shown: the `unread`
          filter's membership is resolved against live read state per page, so
          a post read since the stamp is dropped from the response rather than
          listed as unread. A page may therefore come back shorter than
          `per_page` while `meta.total_count` (the frozen window) still
          describes what is left to page through.
        schema:
          type: string
          format: date-time
      responses:
        '200':
          description: Paginated feed list
          content:
            application/json:
              schema:
                type: object
                required:
                - feeds
                - meta
                properties:
                  feeds:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedSummary"
                  meta:
                    type: object
                    properties:
                      filter:
                        type: string
                        example: all
                        description: Absent on a search (`q`).
                      query:
                        type: string
                        example: fire drill
                        description: 'Only on a search: the trimmed `q`.'
                      page:
                        type: integer
                        example: 1
                      per_page:
                        type: integer
                        example: 20
                      total_pages:
                        type: integer
                        example: 5
                      total_count:
                        type: integer
                        example: 87
                      as_of:
                        type: string
                        format: date-time
                        description: |
                          The read-state cutoff this page was ordered against.
                          Echo it back as the `as_of` query param on page 2+ to
                          hold the window still; request page 1 without it to
                          start a fresh session. Always present.
                        example: '2026-08-31T09:15:00Z'
                      last_visit_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: |
                          Page 1 only (absent on page 2+). When the caller last
                          looked at the feed before this visit — the newest
                          moment one of their posts was marked seen
                          (`POST /feeds/{id}/mark_seen`, or on the web) before
                          `as_of`. `null` on a first-ever visit. Marking cards
                          seen during this visit records the NEXT visit; it does
                          not move this value until page 1 is requested again.
                          A post is new when its `published_at` is later than
                          this and the caller is not its author.
                        example: '2026-08-30T17:42:10.123456Z'
                      new_since_boundary_id:
                        type: integer
                        nullable: true
                        description: |
                          Page 1 only (absent on page 2+). The id of the post the
                          "New since your last visit" divider goes UNDER, in this
                          page's order — the web feed's rule: the run of new
                          posts at the very top of the list. `null` for no
                          divider: no new post at the top, every post on the page
                          new, a first visit, or `filter=pinned`.
                        example: 4182
                      unread_counts:
                        type: object
                        description: |
                          Per-scope unread totals for the caller. Only
                          included when `filter=all`. Reuses the index
                          visibility constraints — same audience, same
                          published+active filter.
                        properties:
                          all:
                            type: integer
                            example: 12
                          must_read:
                            type: integer
                            example: 3
                          company_news:
                            type: integer
                            example: 2
                          operational:
                            type: integer
                            example: 7
                          pinned:
                            type: integer
                            example: 1
                          social:
                            type: integer
                            example: 2
                          mentions:
                            type: integer
                            example: 4
                      unacked:
                        type: boolean
                        description: |
                          Only with `filter=must_read`: whether this list is the
                          `unacked=1` "still waiting on you" narrowing.
                      counts:
                        type: object
                        description: Only with `filter=must_read`.
                        properties:
                          must_read_unacked:
                            type: integer
                            example: 3
                            description: |
                              How many posts the caller still has to acknowledge —
                              the `unacked=1` list's size under the same topic,
                              category and segment filters (the web tab's badge).
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '422':
          description: '`q` is one character (`query_too_short`: "Type at least 2
            characters to search.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: query_too_short
                      message:
                        type: string
                        example: Type at least 2 characters to search.
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Create a feed post (Update, Question, or Poll)
      description: |
        Publishes a feed post. Mirrors the web composer (PRD 02). Server-side
        guardrails:
          * Out-of-policy content_type silently falls back to `update`
          * `operational` (an Announcement) or `must_read` from a caller without
            the "Who can post Announcements & Must-Reads" grant is refused with
            422 and the reason (`capabilities.can_post_announcement` /
            `can_post_must_read` on `GET /news-feed/settings` say which a caller
            may send). With no `priority`, the post goes out as `operational`
            for a caller who may announce and as `social` for everyone else
          * `priority=must_read` on a `question` or `poll` falls back to
            `operational` — must-read is reserved for one-way operational
            broadcasts, not interactive prompts
          * Out-of-policy `audience_type=everyone` falls back to `segments`
          * For `question`, the title is derived from the first non-blank line of `body` if omitted
          * For `poll`, `poll_duration_days` (default 7, max 30) sets `closes_at`.
            Pass the string `"never"` to create a poll that never auto-closes
            (`closes_at` is null) — close it later via `POST /feeds/{id}/close_poll`.

        Publish-time fan-out (audience snapshot, recipient notifications,
        webhooks, poll-close scheduling, SME routing, auto-answer) happens
        asynchronously via PublishFeedWorkflowJob — the response returns as
        soon as the row is persisted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feed
              properties:
                feed:
                  type: object
                  properties:
                    content_type:
                      type: string
                      enum:
                      - update
                      - question
                      - poll
                      default: update
                    title:
                      type: string
                      description: Required for poll. Derived from body.first_line
                        for question.
                    body:
                      type: string
                    priority:
                      type: string
                      enum:
                      - must_read
                      - operational
                      - social
                      description: 'Omitted: `operational` for a caller who may send
                        Announcements, `social` otherwise.'
                    content_category:
                      type: string
                      enum:
                      - operational
                      - company_news
                      description: Optional. `company_news` files an Operational or
                        Must Read post under the feed's Company News tab; `operational`
                        (the default) leaves the category to be derived from `priority`.
                        Ignored on a Social post, which is always `social`.
                    audience_type:
                      type: string
                      enum:
                      - everyone
                      - segments
                      default: everyone
                    audience_segment_ids:
                      type: array
                      items:
                        type: string
                    audience_criteria:
                      type: array
                      description: |
                        Structured attribute targeting — the same shapes the
                        Broadcast kind accepts, so one recipient picker serves
                        all four kinds of the Communications composer. Unioned
                        with `extra_user_ids` and any `audience_segment_ids`
                        sent alongside, then RESOLVED at send time onto the
                        feed row (a frozen recipient list, not a stored rule —
                        the fan-out and the acknowledgement denominator are
                        both taken now).

                        Unknown criterion types, and ids belonging to another
                        tenant, are dropped server-side. An `everyone`
                        criterion absorbs the rest of the selection.

                        A structured selection that resolves to NOBODY is a 422
                        (`error.code: no_recipients`), never a silent widening
                        to everyone.
                      items:
                        type: object
                        properties:
                          type:
                            type: string
                            enum:
                            - everyone
                            - department
                            - job_title
                            - location
                            - role
                          ids:
                            type: array
                            items:
                              type: integer
                            description: For `department` / `location`.
                          titles:
                            type: array
                            items:
                              type: string
                            description: For `job_title`.
                          roles:
                            type: array
                            items:
                              type: string
                              enum:
                              - admin
                              - manager
                              - member
                            description: For `role`.
                      example:
                      - type: department
                        ids:
                        - 3
                        - 8
                      - type: role
                        roles:
                        - manager
                    extra_user_ids:
                      type: array
                      items:
                        type: integer
                      description: |
                        Named individuals to include, unioned with
                        `audience_criteria`. Ids outside this business are
                        dropped.
                    policy_id:
                      type: integer
                      description: |
                        The HR policy this Must-Read asks readers to accept
                        (options: `GET /news-feed/policies`; one policy's
                        detail: `GET /news-feed/policies/{id}`). Honored only when
                        `priority` is `must_read` — the other kinds carry no
                        acknowledgement to compare an acceptance against — and
                        re-scoped to this business, so a crafted id cannot
                        attach another tenant's policy. Read back as the
                        `policy` block on the feed payload. `hr_policy_id` is
                        accepted as an alias.
                    expires_at:
                      type: string
                      format: date-time
                    scheduled_at:
                      type: string
                      format: date-time
                    requires_acknowledgement:
                      type: boolean
                    ai_assisted:
                      type: boolean
                    status:
                      type: string
                      enum:
                      - draft
                      - scheduled
                      - published
                      description: Optional. `published` is the default; `draft` saves
                        without publishing.
                    poll_duration_days:
                      description: Days until the poll auto-closes (1-30, default
                        7), or the string "never" for a poll that stays open until
                        closed manually (closes_at null).
                      default: 7
                      oneOf:
                      - type: integer
                        minimum: 1
                        maximum: 30
                      - type: string
                        enum:
                        - never
                    poll_options_attributes:
                      type: array
                      description: Required when content_type=poll. 2-20 options.
                      items:
                        type: object
                        properties:
                          text:
                            type: string
                          position:
                            type: integer
                    poll_config_attributes:
                      type: object
                      properties:
                        voting_mode:
                          type: string
                          enum:
                          - single
                          - multiple
                        is_anonymous:
                          type: boolean
                        result_visibility:
                          type: string
                          enum:
                          - live
                          - hidden_until_close
                        allow_change_vote:
                          type: boolean
                          default: true
                          description: When false, voters cannot change their vote
                            after submitting.
                        allow_comments:
                          type: boolean
                          default: true
                          description: 'When false, the post/poll is created with
                            comments turned off (no comment input shown, returned
                            as `poll.allow_comments: false`). Mirrors the read shape,
                            where this flag is surfaced inside the `poll` block. May
                            also be sent top-level as `feed[allow_comments]`. Backed
                            by the feed-level `comments_enabled` column, distinct
                            from the reversible moderation discussion-close.'
                    feed_media_ids:
                      type: array
                      items:
                        type: integer
                      description: |
                        Ids of FeedMedia rows pre-uploaded via `POST /media`
                        and finalized via `POST /media/{id}/complete`. Each
                        row must still be orphan (not already attached) and
                        owned by the caller; gallery order matches the
                        order of ids in this array. Reject reasons surface
                        as 422 with `error.message` describing which ids
                        failed.
                rejected_topics:
                  type: array
                  items:
                    type: string
                  description: Topic names the author dismissed in the composer suggestion
                    strip (PRD 11).
                campaign_id:
                  type: integer
                  nullable: true
                  description: |
                    Optional — the Communications campaign to put the post in
                    (choices: `GET /comms_hub/campaigns`); null (form-encoded: an empty
                    value) takes it out,
                    and leaving it out keeps whatever campaign it is in. The
                    post is saved either way: a campaign the caller may not
                    use, or one that is finished, comes back as a `warnings`
                    sentence saying why it is not in it — also for someone with
                    no campaign to use, and for a token without
                    `write:broadcasts` (the scope that changes campaigns).
                    Naming the campaign it is already in (or null for one in
                    none) changes nothing.
                where:
                  "$ref": "#/components/schemas/FeedWhereChoice"
      responses:
        '201':
          description: Post created
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Present when the post was saved but not put in the
                      campaign `campaign_id` named — says why.
                  where_it_shows:
                    "$ref": "#/components/schemas/FeedWhereItShowsResult"
        '400':
          description: Missing `feed` parameter
        '401':
          description: Unauthorized
        '403':
          description: Composer disabled for this business
        '422':
          description: |
            Validation failed, or the structured audience selection
            (`audience_criteria` / `extra_user_ids`) resolved to nobody —
            `error.code: no_recipients`. The post is NOT created and the
            audience is never silently widened to everyone. `place_refused`:
            a place ticked in `where` is not open to this person for this
            audience (the message says why), and nothing was sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - validation_failed
                        - no_recipients
                        - place_refused
                        example: validation_failed
                      message:
                        type: string
                      details:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
  "/feeds/{id}":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Get a feed with the first page of top-level comments
      description: |
        Returns the feed detail payload + the first page (20) of top-level
        comments with replies inlined. The feed payload embeds the latest
        AI discussion summary (`ai_summary`) and, for polls, the latest AI
        outcome summary (`poll_summary`) so mobile clients render the same
        cards as the web view in a single roundtrip. Both follow the
        gating rules of their web partials — see `NewsFeedDetail`.

        Who may open it: the post's audience; before it goes out, only the
        people preparing it. A post that has gone out also opens for the
        holder of the "view any post" grant (Communications › Who can view
        any post — Communications app admins by default) outside its
        audience, as the web permalink does; that read is recorded, and the
        payload carries `can_engage: false`, since the engagement endpoints
        refuse a reader outside the audience. Its attachments, thread pages
        and poll results (`GET /feeds/{id}/media`, `/comments`,
        `/poll_summary`, `/poll_option_voters`) open for them too.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Feed detail + embedded comments page
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
                  comments:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          "$ref": "#/components/schemas/NewsFeedComment"
                      meta:
                        type: object
                        properties:
                          current_page:
                            type: integer
                          per_page:
                            type: integer
                          total_count:
                            type: integer
                          total_pages:
                            type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
          an unsent post the caller does not prepare:
          or a sent post outside the caller's audience they may not view:
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Edit a feed post
      description: |
        Update a feed post. Mirrors the web composer's edit semantics from
        `Apps::NewsFeed::FeedsController#update`:

          * **Draft / scheduled posts** — author may update the full composer
            param set (title, body, priority, audience, scheduling, poll
            configuration). Submitting `status=draft` keeps it a draft; any
            other value resolves to `published` (immediate) or `scheduled`
            (when `scheduled_at` is in the future).
          * **Published posts** — only `title` and `body` are accepted, and
            the call is gated on BOTH the 15-min author edit window
            (FR-02-17) AND the admin "content_editing" feature flag. Polls
            are immutable once published (FR-02-18). `ai_assisted: true`
            marks a post whose edit accepted AI-written text; on a published
            post the mark only rises — `false` never clears it — and it does
            not by itself stamp the post edited.

        Author-only — non-authors get 403. Outside the edit window or with
        the admin toggle off, the call returns 403.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feed
              properties:
                feed:
                  type: object
                  description: |
                    Published posts: `{title, body}` (plus `ai_assisted: true`),
                    `priority`, `content_category`, `allow_comments`, and a
                    must-read's acknowledgement window (`expires_at`, `due_at`).
                    Draft/scheduled: full composer param set (see POST /feeds).
                  properties:
                    title:
                      type: string
                    body:
                      type: string
                    ai_assisted:
                      type: boolean
                      description: True when the edit accepted AI-written text (AI
                        enhance). On a published post the mark only rises — false
                        never clears it.
                    priority:
                      type: string
                      enum:
                      - must_read
                      - operational
                      - social
                    content_category:
                      type: string
                      enum:
                      - operational
                      - company_news
                      description: Optional — company_news marks the post as company
                        news; operational clears it. Ignored on social posts.
                    audience_type:
                      type: string
                      enum:
                      - everyone
                      - segments
                    audience_segment_ids:
                      type: array
                      items:
                        type: string
                    scheduled_at:
                      type: string
                      format: date-time
                      nullable: true
                    expires_at:
                      type: string
                      format: date-time
                      nullable: true
                      description: |
                        When the post stops asking (and, for a post that asks
                        no acknowledgement, when it leaves the feed). On a
                        published must-read with a separate `due_at`, sent
                        alone it moves only this end — unless it lands before
                        `due_at`, which then moves to it. The stored value sent
                        back unchanged moves nothing. Send `due_at` with it to
                        set each end.
                    due_at:
                      type: string
                      format: date-time
                      nullable: true
                      description: |
                        Published must-reads only (ignored on a post that asks
                        no acknowledgement). Sent alone, the new
                        acknowledge-by moment — `expires_at` moves with it,
                        keeping the extra asking days; sent with `expires_at`,
                        each end as sent (a `due_at` after `expires_at` is
                        refused with 422).
                    requires_acknowledgement:
                      type: boolean
                    status:
                      type: string
                      enum:
                      - draft
                      - scheduled
                      - published
                author_topics:
                  type: array
                  items:
                    type: string
                  description: |
                    Topic names the author attached without a literal `#name`
                    in the body. Added to the post; on their own they never
                    remove a topic (see `author_topics_listed`).
                rejected_topics:
                  type: array
                  items:
                    type: string
                  description: Topic names the author dismissed in the composer suggestion
                    strip (PRD 11).
                author_topics_listed:
                  type: boolean
                  description: |
                    Draft and scheduled posts only. True when the client showed
                    the post's own author topics and sends all of them in
                    `author_topics` (an empty list included): an author topic
                    left out — and not a `#hashtag` still in the body — is
                    removed. Topics the AI assigned are never removed. A post
                    waiting for approval whose topics change starts its review
                    again, under the workflow that now applies to it (a review
                    opened before topics counted toward approval is left as it is).
                campaign_id:
                  type: integer
                  nullable: true
                  description: |
                    Optional — the Communications campaign to put the post in
                    (choices: `GET /comms_hub/campaigns`); null (form-encoded: an empty
                    value) takes it out,
                    and leaving it out keeps whatever campaign it is in. The
                    post is saved either way: a campaign the caller may not
                    use, or one that is finished, comes back as a `warnings`
                    sentence saying why it is not in it — also for someone with
                    no campaign to use, and for a token without
                    `write:broadcasts` (the scope that changes campaigns).
                    Naming the campaign it is already in (or null for one in
                    none) changes nothing.
                where:
                  allOf:
                  - "$ref": "#/components/schemas/FeedWhereChoice"
                  description: |
                    Draft or scheduled posts only (`where_not_editable` on a sent
                    one). Sent, it decides the post's Top of Home and store pin:
                    a place it leaves unticked is taken off. Left out, the
                    places the post already holds stay as they are.
      responses:
        '200':
          description: Updated feed
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Present when the post was saved but not put in the
                      campaign `campaign_id` named — says why.
                  where_it_shows:
                    "$ref": "#/components/schemas/FeedWhereItShowsResult"
        '400':
          description: Missing `feed` parameter
        '401':
          description: Unauthorized
        '403':
          description: |
            Caller is not the author, edit window has elapsed, the admin
            content-editing flag is off, or the post is a published poll.
        '404':
          description: Feed not found
        '422':
          description: |
            Validation failure (`validation_failed`), or `demo_question` — an
            unsent sample-data demo question, which is posted only from its
            example (on the web, in Compose) on the example's terms.
            `place_refused`: a place ticked in `where` is not open to this
            person for this audience, and nothing was saved.
            `where_not_editable`: `where` sent on a post already out.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: validation_failed
                      message:
                        type: string
                      details:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Delete a feed post
      description: |
        Delete a feed post. Author-only — non-authors get 403. Mirrors the
        web composer's `#destroy`: enqueues `NewsFeed::MediaCleanupJob` to
        sweep the attached media and clears the author's draft row when
        the deleted feed was itself a draft. Discussion closes, reactions,
        comments, read records, and acknowledgements cascade via the
        model's `dependent: :destroy` associations.

        Delete-level (#6720 / #6721): requires the `destructive:news_feed` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Feed deleted
        '401':
          description: Unauthorized
        '403':
          description: Caller is not the author
        '404':
          description: Feed not found
  "/feeds/{id}/mute":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mute notifications for a feed
      description: |
        Mute notifications about activity on this feed (new comments,
        reactions, etc.) for the calling user. Idempotent — repeat POSTs
        return the same `muted_at` timestamp. Visible-to-caller required.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Muted (or already muted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  muted_at:
                    type: string
                    format: date-time
                  message:
                    type: string
                    example: You won't get notifications about this post.
                    description: Confirmation for the app's toast, in the reader's
                      language; on a must-read the reader still owes it adds that
                      they still need to acknowledge it.
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: Feed not found
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unmute notifications for a feed
      description: |
        Remove the caller's notification mute on this feed. Idempotent —
        returns 200 with a confirmation message even if no mute existed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Mute removed (or none existed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Notifications unmuted
                    description: Confirmation for the app's toast.
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: Feed not found
  "/feeds/{id}/close_discussion":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Close discussion on a feed
      description: |
        Close the discussion on a feed (no new comments, no new replies).
        Authorization (PRD 04 FR-04-10): the author may close their own
        feed's discussion, and an admin may close any feed's discussion.
        Existing comments remain visible.

        Delete-level (#6720 / #6721 — a close that `/reopen_discussion` takes
        back still asks the delete scope, #6768): requires the
        `destructive:news_feed` scope. Reopening asks `write:news_feed`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Discussion closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  discussion:
                    type: object
                    properties:
                      open:
                        type: boolean
                      closed_at:
                        type: string
                        format: date-time
                        nullable: true
                      closed_by_user_id:
                        type: integer
                        nullable: true
                      reopened_at:
                        type: string
                        format: date-time
                        nullable: true
                      reopened_by_user_id:
                        type: integer
                        nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Caller is neither author nor admin
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: Feed not found
        '422':
          description: Discussion is already closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/feeds/{id}/reopen_discussion":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Reopen a closed discussion
      description: |
        Reopen a previously-closed discussion. Authorization (PRD 04
        FR-04-11): admin-only — authors cannot reopen their own closed
        discussions.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Discussion reopened
          content:
            application/json:
              schema:
                type: object
                properties:
                  discussion:
                    type: object
                    properties:
                      open:
                        type: boolean
                      closed_at:
                        type: string
                        format: date-time
                        nullable: true
                      closed_by_user_id:
                        type: integer
                        nullable: true
                      reopened_at:
                        type: string
                        format: date-time
                        nullable: true
                      reopened_by_user_id:
                        type: integer
                        nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Caller is not an admin
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: Feed not found
        '422':
          description: Discussion is not closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/feeds/{id}/publish":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Publish a scheduled feed immediately
      description: |
        Publish-now for a feed currently in `status=scheduled`. Author-only.
        Mirrors the web composer's "Publish now" action: flips status to
        `published`, clears `scheduled_at`, stamps `published_at`, and
        triggers the same audience snapshot + recipient fan-out workflow
        (`NewsFeed::PublishFeedWorkflowJob`).

        Calling on a feed that isn't in `scheduled` state returns 422.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Feed published
          content:
            application/json:
              schema:
                type: object
                properties:
                  feed:
                    "$ref": "#/components/schemas/NewsFeedDetail"
        '401':
          description: Unauthorized
        '403':
          description: |
            Caller may not send it: not the author or a Communications admin;
            or the post is being written by a delegate for someone else, which
            only its author and that delegate send.
        '404':
          description: Feed not found
        '422':
          description: |
            Feed is not in `scheduled` state (`not_scheduled`), or it is an
            unsent sample-data demo question (`demo_question`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: not_scheduled
                      message:
                        type: string
  "/feeds/{id}/pin":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Pin a feed for the calling user
      description: |
        Personal pin — the post surfaces in the calling user's `filter=pinned`
        listing only. Pinning has no effect on any other viewer's feed. Any
        user in the post's audience can pin a live post — `can_pin` on the
        post says so. A post outside the caller's
        audience answers 404, the same as one that does not exist. A draft or
        scheduled post answers 422 `not_published` to the people preparing
        it — nobody pins a post before it goes out — and 404 to everyone else.
        An expired or archived post answers 422 `not_pinnable`: the pinned
        list shows only published, unexpired posts. Unpin stays open.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Pinned for the calling user
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  pinned:
                    type: boolean
                    example: true
                    description: Per-user — reflects the calling user's pin state
                      only.
                  pinned_at:
                    type: string
                    format: date-time
                    description: When the calling user pinned the post.
        '401':
          description: Unauthorized
        '403':
          description: The post is listed for the caller but they may not open it
        '404':
          description: Feed not found
          or outside the caller's audience:
        '422':
          description: "`not_published` — a draft or scheduled post, to someone preparing
            it; `not_pinnable` — an expired or archived post"
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unpin a feed for the calling user
      description: |
        Removes the calling user's personal pin on the post. Idempotent —
        succeeds whether or not a pin row existed, and open on an expired or
        archived post, so a pin that went stale can be removed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Unpinned for the calling user
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  pinned:
                    type: boolean
                    example: false
        '401':
          description: Unauthorized
        '403':
          description: Caller is not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: "`not_published` — a draft or scheduled post, to someone preparing
            it"
  "/feeds/{id}/comments":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List comments on a feed
      description: |
        Returns the active comments page-by-page. When `parent_comment_id`
        is set, returns the replies of that comment instead of top-level
        ones.

        Each top-level comment includes its direct replies inline under
        `replies[]` (depth-1, ordered by `created_at` ascending). Both
        the top-level comment and each reply carry their own `media[]`
        attachments, so clients do not need a follow-up request per
        comment to render the thread.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: parent_comment_id
        in: query
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: Paginated comments
          content:
            application/json:
              schema:
                type: object
                properties:
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedComment"
                  meta:
                    type: object
                    properties:
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Create a comment on a feed
      description: |
        Creates a top-level comment or a reply. PRD 04 Phase 16 adds optional
        attachments via `comment_media_ids[]`: mobile uploads orphan media
        through `POST /media` + `POST /media/{id}/complete` first, then sends
        the resulting ids here. The server claims them inside the create
        transaction (locked `FOR UPDATE`); any rejection — wrong owner,
        already attached, soft-deleted, over the per-comment cap of 10 —
        rolls the whole comment back so partial attachment sets are
        impossible. `body` may be blank when at least one attachment is
        provided ("attachment-only" comments).

        The company's moderation settings apply. A comment held for review
        comes back 201 with `held_for_review: true`: only its author is ever
        sent it, until a moderator approves it. Send `moderation_nudge: true`
        to stop first instead: flagged words answer 422 with
        `error.code: moderation_nudge` and `error.moderation_nudge` (`reason`
        to show the author, `token`, `can_send_for_review`), and nothing is
        saved. The same words sent again with `moderation_nudge_token` are
        held for a moderator; changed words are screened from the start.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: |
                    Comment text (1..2000 chars). May be blank only when
                    `comment_media_ids` is non-empty.
                parent_comment_id:
                  type: integer
                  description: Omit for top-level; provide to reply.
                ai_assisted:
                  type: boolean
                moderation_nudge:
                  type: boolean
                  description: This client can show the author why their words were
                    flagged.
                moderation_nudge_token:
                  type: string
                  description: The token from a moderation_nudge answer, sent with
                    the same words.
                comment_media_ids:
                  type: array
                  items:
                    type: integer
                  description: |
                    Ids of FeedMedia rows pre-uploaded via `POST /media`
                    and finalized via `POST /media/{id}/complete`. Each
                    row must still be orphan (not yet attached) and owned
                    by the caller; gallery order matches the order of ids
                    in this array. Cap of 10 attachments per comment.
                    Mirror of `feed_media_ids` on `POST /feeds`. Reject
                    reasons surface as 422 with `error.message`.
      responses:
        '201':
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/NewsFeedComment"
        '401':
          description: Unauthorized
        '403':
          description: |
            `discussion_closed` — the post takes no comments: written with
            comments off, its discussion closed, or a recognition's mirrored
            post whose comments live on the recognition (the post's
            `comments_allowed` is false; `social_thread` names where). Also
            `comments_disabled` / `discussion_layer_disabled` when the tenant
            turned comments or the discussion layer off.
        '404':
          description: Feed not found
        '422':
          description: Validation failed (body length, depth exceeded, media not owned
            / already claimed / cap exceeded, etc.)
  "/feeds/{id}/reactions":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List reactors on a feed (flat + grouped-by-emoji)
      description: |
        Returns the reactors on a feed in two shapes:

          * `reactors` — flat list, ordered most-recent first; used by the
            mobile popover row.
          * `grouped`  — same reactors bucketed by `emoji_key` and sorted
            by count desc; used by the reactor sheet's per-emoji tabs.

        Capped at the most recent 200 reactors.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Flat + grouped reactor lists
          content:
            application/json:
              schema:
                type: object
                required:
                - reactors
                - grouped
                - total
                properties:
                  total:
                    type: integer
                    minimum: 0
                  reactors:
                    type: array
                    items:
                      type: object
                      properties:
                        emoji_key:
                          type: string
                        tier:
                          type: string
                        reacted_at:
                          type: string
                          format: date-time
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              nullable: true
                            avatar_url:
                              type: string
                              nullable: true
                              format: uri
                  grouped:
                    type: array
                    items:
                      type: object
                      properties:
                        emoji_key:
                          type: string
                        tier:
                          type: string
                        count:
                          type: integer
                          minimum: 0
                        users:
                          type: array
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                                nullable: true
                              avatar_url:
                                type: string
                                nullable: true
                                format: uri
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: React to a feed (set, swap or toggle off)
      description: |
        At most one reaction per user per feed. POSTing an emoji when the
        caller has none adds it; a different emoji swaps it in place; the
        SAME emoji removes it (a toggle — `reaction` is then null). Every
        branch answers 200 with the feed's refreshed `aggregate`. DELETE
        removes the caller's reaction whatever its emoji.

        The emoji must be in the business's reaction palette, except a
        reaction the caller already holds, which can always be toggled off.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. The sign-in tokens and a token made
        by hand hold it; a browser session is asked no scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji_key
              properties:
                emoji_key:
                  type: string
                  description: One of the 14 allowed NewsFeed emoji keys (e.g., `like`,
                    `love`, `celebrate`, `insightful`).
      responses:
        '200':
          description: The caller's reaction after the tap, and the feed's aggregate
          content:
            application/json:
              schema:
                type: object
                required:
                - reaction
                - aggregate
                properties:
                  reaction:
                    allOf:
                    - type: object
                      description: One person's reaction on a post or a comment; exactly
                        one of `feed_id` / `comment_id` is set.
                      required:
                      - id
                      - feed_id
                      - comment_id
                      - emoji_key
                      - tier
                      - user_id
                      - reacted_at
                      - created_at
                      properties:
                        id:
                          type: integer
                        feed_id:
                          type: integer
                          nullable: true
                          description: Set for a reaction on a post; null on a comment's.
                        comment_id:
                          type: integer
                          nullable: true
                          description: Set for a reaction on a comment; null on a
                            post's.
                        emoji_key:
                          "$ref": "#/components/schemas/NewsFeedEmojiKey"
                        tier:
                          type: string
                          enum:
                          - expressive
                          - action_oriented
                        user_id:
                          type: integer
                        reacted_at:
                          type: string
                          format: date-time
                          description: When the reaction was set or last swapped.
                        created_at:
                          type: string
                          format: date-time
                    nullable: true
                    description: Null when the tap toggled the caller's reaction off.
                  aggregate:
                    "$ref": "#/components/schemas/NewsFeedReactionsAggregate"
        '401':
          description: Unauthorized
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business."
            - `not_authorized` — "You do not have access to this feed": the caller is not in the post's audience.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Feed not found
        '422':
          description: |
            - `invalid_emoji` — "Emoji is not in the active palette".
            - `not_published` — "This post hasn't been published yet.": the post is a draft or scheduled, and the caller is preparing it.
            - `reactions_closed` — "Reactions are off for this post.": the post takes no reactions (`reactions_enabled` false); taking your own off still works.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Remove the caller's reaction on a feed
      description: |
        Removes the caller's reaction from the post, whatever its emoji, and
        sends the new count to everyone viewing it, as adding one does.
        Answers `204` whether or not there was one.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. The sign-in tokens and a token made
        by hand hold it; a browser session is asked no scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Reaction removed (or already absent — idempotent)
        '401':
          description: Unauthorized
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business."
            - `not_authorized` — "You do not have access to this feed": the caller is not in the post's audience.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Feed not found
        '422':
          description: '`not_published` — "This post hasn''t been published yet.":
            the post is a draft or scheduled, and the caller is preparing it.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{id}/correct_answer":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Read the current verified answer on a Question post
      description: |
        Returns the marked correct answer or `null` when none has been
        marked yet. Same shape as the embedded `correct_answer` block on
        `GET /feeds/{id}`, so clients can reuse one renderer.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Current correct answer (or null)
          content:
            application/json:
              schema:
                type: object
                properties:
                  correct_answer:
                    nullable: true
                    allOf:
                    - "$ref": "#/components/schemas/NewsFeedCorrectAnswer"
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience, or correct-answer marking
            disabled for the business
        '404':
          description: Feed not found
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark a comment as the verified answer (Question posts only)
      description: |
        Only the post author or a News Feed admin may mark the correct answer.
        Locks once the discussion is closed. Replacing an existing marked
        answer is a single transactional swap (delete + insert).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment_id
              properties:
                comment_id:
                  type: integer
      responses:
        '200':
          description: Verified answer marked
          content:
            application/json:
              schema:
                type: object
                properties:
                  correct_answer:
                    "$ref": "#/components/schemas/NewsFeedCorrectAnswer"
        '401':
          description: Unauthorized
        '403':
          description: Caller not authorized to mark the correct answer, or correct-answer
            marking disabled for the business
        '404':
          description: Feed or comment not found
        '422':
          description: |
            Returned when the feed is not a Question, the discussion is closed,
            or the chosen comment has been deleted. `error.code` discriminates.
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unmark the verified answer on a Question post
      description: |
        Only the post author or a News Feed admin may unmark. Idempotent —
        returns `correct_answer: null` even when nothing was marked.
        Locks once the discussion is closed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Verified answer removed (or already absent — idempotent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  correct_answer:
                    nullable: true
                    type: object
                    example:
        '401':
          description: Unauthorized
        '403':
          description: Caller not authorized to unmark, or correct-answer marking
            disabled for the business
        '404':
          description: Feed not found
        '422':
          description: Discussion is closed
  "/feeds/{feed_id}/comments/{id}/not_solved":
    parameters:
    - in: path
      name: feed_id
      required: true
      description: The Question post's id.
      schema:
        type: integer
    - in: path
      name: id
      required: true
      description: The id of the expert's reply — a top-level comment on that post
        whose `expert_answer.can_mark_not_solved` is true.
      schema:
        type: integer
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Tell an expert's reply "This didn't solve it"
      description: |
        The asker's other answer to "Did this solve it?" under a reply from an
        expert their question was sent to ("Mark as correct" is
        `POST /feeds/{id}/correct_answer`). Records it on that reply — every
        reader of the post then sees `expert_answer.state: not_solved` on it —
        asks the next expert now where there is one, and tells the expert
        whose reply it was. Only the person who asked the question may say
        it, once per expert, while the discussion is open and no answer is
        marked correct. Takes no body.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      responses:
        '200':
          description: Recorded.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                - expert_answer
                properties:
                  message:
                    type: string
                    description: The sentence to show the asker — what happened next
                      (another expert was asked, or why nobody was).
                    example: Thanks for telling us — we've asked another expert.
                  expert_answer:
                    "$ref": "#/components/schemas/NewsFeedExpertAnswer"
              example:
                message: Thanks for telling us — we've asked another expert.
                expert_answer:
                  state: not_solved
                  can_mark_correct: false
                  can_mark_not_solved: false
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `not_allowed` "Only the person who asked the question can say this."
            - `not_available` "This isn't available for this question." — for one, the company has correct answers switched off.
            - `discussion_layer_disabled` — discussions are switched off for the business.
            - `not_authorized` "You do not have access to this feed" — the caller is not in the post's audience.
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` "Feed not found" — no such post in the business, or a draft or scheduled post the caller is not preparing.
            - `comment_not_found` "Comment not found on this feed" — no comment with this id on that post.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            Nothing was recorded; `error.message` is the sentence to show.
            - `already_not_solved` "You've already told us this didn't solve it."
            - `already_answered` "This question already has a correct answer."
            - `discussion_closed` "The discussion on this question is closed."
            - `question_ended` "This question has expired or been archived, so no other expert can be asked."
            - `comment_deleted` "That reply is no longer available."
            - `not_an_expert_reply` "This reply isn't from an expert the question was sent to." — also a reply under a comment.
            - `not_published` — the post has not gone out yet (only its preparers get this).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{id}/mark_seen":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark a feed as read by the caller
      description: |
        Upserts a NewsFeed::ReadRecord. `read_at` is the first-view timestamp;
        `last_seen_at` is the most-recent-view timestamp (used by the "new
        replies since" preview card).

        A must-read post logs NO view until the caller acknowledges it. While
        an acknowledgement is still owed the call is accepted but records
        nothing, and the response carries `logged: false` with
        `acknowledgement_required: true` and null timestamps. Acknowledging
        the post logs the view; every later call then behaves normally. The
        gate lifts once the acknowledgement window has closed, since nobody
        can acknowledge an expired must-read.

        Pass `scroll_depth_pct` when the client can measure how much of the
        post body the reader actually saw. At >= 80 the read is promoted to a
        QUALIFIED read (PRD 18 FR-18-01, set-once) and counts toward the Read
        rate tile in post analytics; omit it and the call records a plain
        impression, which appears in the viewers roster but not in Read rate.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: scroll_depth_pct
        in: query
        description: Percentage of the post body that entered the viewport, 0-100.
          Values >= 80 promote this read to a qualified read.
        schema:
          type: number
          minimum: 0
          maximum: 100
      responses:
        '200':
          description: Read record persisted, or declined pending acknowledgement
          content:
            application/json:
              schema:
                type: object
                properties:
                  logged:
                    type: boolean
                    description: False when nothing was recorded because the caller
                      still owes an acknowledgement on a must-read post.
                    example: true
                  acknowledgement_required:
                    type: boolean
                    description: Sent as true only when `logged` is false — the caller
                      clears it by acknowledging the post.
                  read_at:
                    type: string
                    format: date-time
                    nullable: true
                  last_seen_at:
                    type: string
                    format: date-time
                    nullable: true
                  qualified_read_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: Set once, the first time this feed was read with
                      scroll_depth_pct >= 80. Null while the caller has only ever
                      recorded impressions.
                  scroll_depth_pct:
                    type: number
                    nullable: true
                    description: Scroll depth stored on the qualifying read, 0-100.
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: "`not_published` — a draft or scheduled post, to someone preparing
            it: nothing is recorded before it goes out"
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark a feed as unread (remove the caller's read record)
      description: Idempotent — returns 200 even when there is no read record.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Read record cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  read:
                    type: boolean
                    example: false
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
  "/feeds/where_it_shows":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: The phone composer's "Where it shows" places
      description: |
        The places this person may put a post with this audience — the /m/
        composer's card, from the same code (`Comms::PhoneWhereItShows`). News
        and Inbox always apply and are not listed. "Skip News and Inbox" is a
        desktop-only choice and is never offered here. Send the ticks back as
        `where` on `POST /feeds` or `PATCH /feeds/{id}`; they are checked again
        there for the audience the post is sent with.

        Takes the audience fields of `POST /feeds` as query parameters —
        `feed[audience_type]`, `feed[audience_segment_ids][]`,
        `feed[audience_criteria]` (JSON), `feed[extra_user_ids][]`; none means
        everyone. A selection that reaches nobody offers no place, and neither
        does a token without `write:news_feed` — the scope `POST /feeds` and
        `PATCH /feeds/{id}` ask to book one.
      parameters:
      - name: draft_id
        in: query
        required: false
        schema:
          type: integer
        description: |
          A reopened draft or scheduled post the caller may edit (its author,
          or an app admin sending a teammate's) — the places it already holds
          come back `checked`. 404 `not_found` for one the caller can't edit,
          or that has been sent or deleted.
      responses:
        '200':
          description: The places offered; a null place is not offered to this person
            for this audience.
          content:
            application/json:
              schema:
                type: object
                properties:
                  top_of_home:
                    type: object
                    nullable: true
                    properties:
                      checked:
                        type: boolean
                      note:
                        type: string
                        description: When it would start for these people (Top of
                          Home runs 7 days from the first free start).
                  store_pin:
                    type: object
                    nullable: true
                    description: Offered when everyone the post reaches works at one
                      store the author manages.
                    properties:
                      checked:
                        type: boolean
                      label:
                        type: string
                        example: Pin on Store 214's home until
                      default_until:
                        type: string
                        format: date
                      days:
                        type: array
                        items:
                          type: string
                          format: date
                        description: The days the pin may run to — today and the six
                          after.
                      replaces:
                        type: string
                        nullable: true
                        description: The pin it replaces, e.g. "Replaces “Truck moved
                          to 10:00 AM”, pinned yesterday."
        '401':
          description: Unauthorized
        '403':
          description: Composer disabled for this business, or the token lacks `read:news_feed`
        '404':
          description: "`draft_id` names a post the caller cannot edit, or one already
            sent or deleted (`not_found`)."
        '503':
          description: The places could not be checked (`where_it_shows_unavailable`)
            — say so, never "no places".
  "/feeds/mark_all_read":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark every visible feed as read for the caller
      description: |
        Bulk-marks every audience-visible, published, non-expired feed as
        read for the caller. Inserts a NewsFeed::ReadRecord row (with
        `read_at` and `last_seen_at` set to now) for each previously
        unread feed; feeds the caller has already read are left alone.

        Must-read posts the caller has not acknowledged are EXCLUDED and are
        not counted in `marked_count` — a pending acknowledgement cannot be
        cleared in bulk, only by acknowledging the post itself.

        Idempotent — safe to call repeatedly. Returns the number of
        feeds newly marked so the client can update its unread badges
        optimistically.
      responses:
        '200':
          description: Bulk read receipts persisted
          content:
            application/json:
              schema:
                type: object
                required:
                - marked_count
                - marked_at
                properties:
                  marked_count:
                    type: integer
                    description: Number of feeds newly marked as read on this call.
                    example: 12
                  marked_at:
                    type: string
                    format: date-time
                    description: Timestamp assigned to the inserted read records.
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/feeds/{id}/viewers":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List users who have viewed the feed (read receipts)
      description: |
        Read-receipt roster for a feed post. Returns the users who have
        viewed the feed (NewsFeed::ReadRecord rows), ordered
        most-recently-viewed first and paginated. Each row carries the
        canonical v1 user shape (id / name / avatar_url) plus the row
        subtitle (job title / office location) and the most-recent view
        time. Visible to anyone who can see the feed.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: Viewers listed (most-recently-viewed first)
          content:
            application/json:
              schema:
                type: object
                required:
                - viewers
                - meta
                properties:
                  viewers:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - viewed_at
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Viewer's job title (row subtitle — what the
                            request calls "position").
                        location:
                          type: string
                          nullable: true
                          description: Viewer's office location (row subtitle).
                        viewed_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: COALESCE(last_seen_at, read_at) — most-recent
                            view time.
                  meta:
                    type: object
                    required:
                    - unique_view_count
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    properties:
                      unique_view_count:
                        type: integer
                        description: Distinct viewers, everyone counted (equals total_count
                          unless a works council hides some viewers — see names_hidden);
                          only the named viewers while names_hidden.count is null.
                      total_count:
                        type: integer
                        description: Viewers in the named list.
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                  names_hidden:
                    type: object
                    description: |
                      Present only when a works council hides names (General
                      Settings › Countries): the viewers left out of `viewers`
                      — counted, never named.
                      While the hidden group is under the company's smallest reportable
                      group, `count` and `group_count` are null and `fewer_than` is that
                      group, "a few" (the status counts beside it count only the named
                      people) — the shape of `BroadcastNamesHidden`.
                    properties:
                      count:
                        type: integer
                        nullable: true
                      group_count:
                        type: integer
                        nullable: true
                        description: 'Null since #4546: a hidden group under the company''s
                          smallest group is "a few" (`fewer_than`). Kept for older
                          clients.'
                      fewer_than:
                        type: integer
                        description: 'Present only when the people it leaves out (or
                          the hidden group) are fewer than the company''s smallest
                          group: that number — they are "a few", and `count` / `group_count`
                          are null (#4546).'
                      reason:
                        type: string
                        example: Names are hidden for people in Germany (works council).
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
  "/feeds/{id}/acknowledge":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Acknowledge a must-read feed
      description: |
        Records the caller's acknowledgement of a must-read post. Idempotent —
        repeat acknowledgements return the original `acknowledged_at`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  acknowledged_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: Feed not found
        '422':
          description: Feed is not must-read, or has expired
  "/feeds/{id}/acknowledgements":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List the must-read compliance roster (acknowledged or pending)
      description: |
        Compliance roster for a must-read post. A single endpoint backs
        both the "Acknowledged" and "Pending" tabs of the UI — switch
        between them via the `status` query param.

        Each row carries the canonical v1 user shape (id / name / email /
        avatar_url) plus the row subtitle (job title — what callers refer
        to as "position" — and office location) and an `acknowledged_at`
        timestamp (set for the `acknowledged` list, `null` for `pending`).

        Modes (selected by `status`, default `acknowledged`):
          * `acknowledged` — users who have recorded an acknowledgement
            (`NewsFeed::AcknowledgementRecord` rows). Ordered
            most-recently-acknowledged first.
          * `pending` — users in the feed's audience (resolved via
            `NewsFeed::AudienceResolver`) who have NOT yet acknowledged.
            The feed author is excluded (mirrors the bulk-reminder
            fan-out rule). Ordered by user id.

        Authorization mirrors `POST /feeds/{id}/send_reminder`: only the
        feed author, a business admin-or-above, or a `news-feed` app
        admin may view the roster. The feed must have
        `priority='must_read'` (422 otherwise).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: status
        in: query
        required: false
        description: |
          Which slice of the roster to return. Default `acknowledged`.
          Any other value returns 422 `invalid_status`.
        schema:
          type: string
          enum:
          - acknowledged
          - pending
          default: acknowledged
      - name: page
        in: query
        required: false
        schema:
          type: integer
      - name: per_page
        in: query
        description: Items per page (default 20, max 100).
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: |
            Roster slice for the requested `status`. Ordering and the
            meaning of `acknowledged_at` depend on `status` (see
            description).
          content:
            application/json:
              schema:
                type: object
                required:
                - users
                - meta
                properties:
                  users:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - acknowledged_at
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        email:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: User's job title (row subtitle — what the request
                            calls "position").
                        location:
                          type: string
                          nullable: true
                          description: User's office location (row subtitle).
                        acknowledged_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: |
                            When the user acknowledged this must-read post.
                            Always `null` when `status=pending`.
                  meta:
                    type: object
                    required:
                    - total_count
                    - total_pages
                    - current_page
                    - per_page
                    description: Pages the NAMED list; people a works council hides
                      are counted in `names_hidden`.
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                  names_hidden:
                    type: object
                    description: |
                      Present only when a works council hides names (General
                      Settings › Countries): the people left out of `users`
                      — counted, never named.
                      While the hidden group is under the company's smallest reportable
                      group, `count` and `group_count` are null and `fewer_than` is that
                      group, "a few" (the status counts beside it count only the named
                      people) — the shape of `BroadcastNamesHidden`.
                    properties:
                      count:
                        type: integer
                        nullable: true
                      group_count:
                        type: integer
                        nullable: true
                        description: 'Null since #4546: a hidden group under the company''s
                          smallest group is "a few" (`fewer_than`). Kept for older
                          clients.'
                      fewer_than:
                        type: integer
                        description: 'Present only when the people it leaves out (or
                          the hidden group) are fewer than the company''s smallest
                          group: that number — they are "a few", and `count` / `group_count`
                          are null (#4546).'
                      reason:
                        type: string
                        example: Names are hidden for people in Germany (works council).
        '401':
          description: Unauthorized
        '403':
          description: Caller is not author / admin / app-admin
        '404':
          description: Feed not found
        '422':
          description: |
            Feed is not a must-read post, OR the `status` query param is
            not one of `acknowledged` / `pending`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - invalid_status
                      message:
                        type: string
  "/feeds/{id}/send_reminder":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Send a manual acknowledgement reminder (bulk or single user)
      description: |
        Triggers `NewsFeed::MustReadReminderJob` (the same job the automated
        scheduler enqueues) to remind users who haven't yet acknowledged
        this must-read post. Two modes, selected by the optional `user_id`
        field in the request body:

        **Bulk mode (omit `user_id`)** — mirrors the web
        `Apps::NewsFeed::AcknowledgementsController#send_reminder` exactly:

          * The feed must have `priority='must_read'` — 422 otherwise.
          * Caller must be the feed author, a business admin-or-above, or
            an app admin for `news-feed` — 403 otherwise.
          * 24-hour cooldown: only one bulk reminder per feed per day,
            tracked by `feeds.last_reminder_sent_at`. Subsequent calls
            inside the window return 429.

        On success, stamps `last_reminder_sent_at = Time.current` and
        returns the new value as `sent_at`. Fan-out reaches every audience
        user who hasn't already acknowledged, hasn't muted the post, and
        isn't the author.

        **Targeted mode (`user_id` present)** — single-recipient nudge,
        mirroring the broadcasts `POST /broadcasts/:id/remind` shape:

          * Same authz gates as bulk.
          * The target user must be in the same business AND in the feed's
            audience (422 `not_a_recipient` otherwise).
          * Cannot target the feed author (422 `cannot_remind_author`).
          * Cannot target a user who has already acknowledged (422
            `already_acknowledged`).
          * Does **not** touch the feed-level 24h cooldown — single-user
            reminders are independent of the bulk path.
          * Mute is intentionally overridden — an explicit single-user
            nudge wins over the recipient's per-post mute.

        On success, returns `{ sent_at, user_id }`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id:
                  type: integer
                  description: |
                    Optional. When omitted, bulk reminder to every pending
                    audience user. When present, targeted reminder to just
                    that user.
      responses:
        '200':
          description: Reminder enqueued
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  description: Bulk mode response
                  properties:
                    sent_at:
                      type: string
                      format: date-time
                - type: object
                  description: Targeted mode response
                  properties:
                    sent_at:
                      type: string
                      format: date-time
                    user_id:
                      type: integer
        '401':
          description: Unauthorized
        '403':
          description: Caller is not author / admin / app-admin
        '404':
          description: Feed not found
        '422':
          description: |
            Feed is not must-read, OR (targeted mode) the user_id is invalid /
            not a recipient / is the author / has already acknowledged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - user_not_found
                        - not_a_recipient
                        - cannot_remind_author
                        - already_acknowledged
                      message:
                        type: string
        '429':
          description: Bulk mode only — reminder already sent in the last 24 hours
  "/comments/{id}":
    put:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Edit a comment
      description: |
        Update a comment's body. Mirrors `NewsFeed::CommentsService.edit`:

          * **Author-only** — non-authors get 403.
          * Gated on the admin "Allow content editing" feature flag — when
            the flag is off, all edits return 403.
          * Edits are restricted to the 15-minute author edit window from
            the comment's `created_at` (FR-04-13). Expired window returns
            422 with `error.code='edit_window_expired'`.
          * A soft-deleted comment — including a reply deleted with its
            parent — answers 404, as a missing one does.

        On success, sets `edited_at = Time.current` and returns the full
        comment payload (same shape as the list/show endpoints).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 2000
                  description: New comment body (1..2000 chars).
      responses:
        '200':
          description: Updated comment
          content:
            application/json:
              schema:
                type: object
                properties:
                  comment:
                    "$ref": "#/components/schemas/NewsFeedComment"
        '401':
          description: Unauthorized
        '403':
          description: 'Caller is not the author, or the content-editing flag is off.

            '
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: not_authorized
                      message:
                        type: string
        '404':
          description: Comment not found or soft-deleted
        '422':
          description: Edit window expired OR validation failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: edit_window_expired
                      message:
                        type: string
                      details:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: string
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Soft-delete a comment
      description: |
        Soft-deletes a comment (`status='deleted'`, body cleared). Mirrors
        `NewsFeed::CommentsService.delete`:

          * Allowed for the comment **author** OR any **business
            admin-or-above** — non-authors / non-admins get 403.
          * Writes a `NewsFeed::AuditLog` row (`delete_own_comment` or
            `admin_delete_comment`) for moderation history.
          * Deleting a top-level comment deletes its replies with it (owner
            decision #7810) — the audit row's `replies_removed` says how many.
            The replies go in a background job seconds after the response; a
            reply deleted with its parent then answers 404 here like any
            deleted comment.

        Returns 204 on success. Delete-level (#6720 / #6721): requires the
        `destructive:news_feed` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Comment soft-deleted
        '401':
          description: Unauthorized
        '403':
          description: Caller is not the author and not an admin
        '404':
          description: Comment not found
  "/feeds/{id}/close_poll":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Close a poll now
      description: |
        Manually close an open poll ahead of (or in the absence of) its
        scheduled `closes_at`. This is the only way to close a poll created
        with `poll_duration_days: "never"`. One-way — there is no reopen, and
        a closed poll's results become final and visible to everyone.
        Authorization (PRD 05): the feed author or an admin.

        Delete-level (#6720 / #6721): requires the `destructive:news_feed` scope.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Poll closed — returns the updated poll block
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll:
                    type: object
                    description: |
                      Poll state embedded on Poll-type feeds. Returned on both list rows
                      and the detail endpoint. Option labels (`options[].id/text/position`)
                      are included on both so cards can render the option list from the
                      list response; per-option vote counts (`options[].votes`) are
                      detail-only to keep list payloads light.
                    properties:
                      closes_at:
                        type: string
                        format: date-time
                        nullable: true
                      voting_mode:
                        type: string
                        nullable: true
                        enum:
                        - single
                        - multi
                        - ranked
                        description: |
                          Controls how `my_vote_option_ids` should be interpreted:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order is not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                      is_anonymous:
                        type: boolean
                        nullable: true
                      result_visibility:
                        type: string
                        nullable: true
                        enum:
                        - live
                        - hidden_until_close
                      allow_change_vote:
                        type: boolean
                        nullable: true
                      can_vote:
                        type: boolean
                        description: |
                          Whether POST (a first vote) or PATCH (a change) on
                          `/feeds/{id}/poll_votes` would take this caller's ballot now — the
                          door's own rule: never through an impersonation token or under
                          View-as, on a draft, a closed poll, a vote that may not change, or
                          for a reader outside the audience. Draw the ballot only when true.
                      allow_comments:
                        type: boolean
                        description: |
                          Whether a comment may be posted on the poll right now
                          (`Feed#comments_allowed?`). Folds the author's create-time
                          comment setting (the feed-level `comments_enabled` column) together
                          with the moderation discussion-close state, so poll cards gate their
                          comment input from one field. Set it at create/update time via
                          `poll_config_attributes[allow_comments]` (or top-level
                          `feed[allow_comments]`).
                      is_closed:
                        type: boolean
                        description: True when poll_config.closes_at has elapsed.
                      option_count:
                        type: integer
                        minimum: 0
                        description: Number of poll options.
                      my_vote_option_ids:
                        type: array
                        description: |
                          The caller's voted option_ids. Always present; empty array when
                          the caller has not voted. Interpret via `voting_mode`:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                        items:
                          type: integer
                      results_visible:
                        type: boolean
                        description: |
                          Detail-only. True when the caller is allowed to see the
                          per-option vote breakdown; false when results are gated.

                          Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
                          surface:
                            * Closed poll → true (everyone)
                            * Author or business admin → true (always)
                            * Open + `result_visibility: hidden_until_close` → false
                            * Open + `result_visibility: live`, caller has voted → true
                            * Open + `result_visibility: live`, caller has not voted → false

                          When false, `total_votes` and each `options[].votes` /
                          `options[].percent` are returned as `null` so clients can render
                          a "results hidden" state without inferring whether anyone has
                          voted yet.
                      voters_visible:
                        type: boolean
                        description: |
                          True when `GET /feeds/{id}/poll_option_voters` answers this caller
                          — draw "See who voted" exactly then. Always false on an anonymous
                          poll (`is_anonymous`); on a named poll it follows the
                          `results_visible` rules above. The same rule as the web and phone
                          "See who voted" and the privacy sentence under every poll.
                      total_votes:
                        type: integer
                        minimum: 0
                        nullable: true
                        description: |
                          Aggregate count of distinct voters (sum of active PollVote rows).
                          Detail-only — returned on `GET /feeds/{id}` and on the
                          `/feeds/{id}/poll_votes` responses, omitted from list rows.
                          `null` when `results_visible` is false.
                      options:
                        type: array
                        description: |
                          Poll options. `id`, `text`, and `position` are returned on both
                          list rows and the detail endpoint. `votes` (per-option count)
                          and `percent` (0–100, rounded) are detail-only — list rows omit
                          them. Both are `null` when `results_visible` is false.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            text:
                              type: string
                            position:
                              type: integer
                            votes:
                              type: integer
                              nullable: true
                              description: Detail endpoint only. `null` when results
                                are gated.
                            percent:
                              type: integer
                              minimum: 0
                              maximum: 100
                              nullable: true
                              description: |
                                Detail endpoint only. Share of `total_votes` cast for this
                                option, rounded to the nearest integer (0–100). `null` when
                                results are gated. Matches the value rendered by the web
                                `_poll` partial so mobile and web stay in lockstep.
        '401':
          description: Unauthorized
        '403':
          description: Caller is neither author nor admin
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '404':
          description: Feed not found
        '422':
          description: Feed is not a poll, or the poll is already closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: poll_already_closed
                      message:
                        type: string
  "/feeds/{id}/poll_votes":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Cast a vote on a poll feed
      description: |
        Records the caller's vote on a poll. Idempotent — if the caller has
        already voted on this poll, returns the existing vote with
        `already_voted: true` (no new row, no counter change).

        For `voting_mode: single` send `poll_option_id`. For `voting_mode:
        multi` send `poll_option_ids` (unordered array). For `voting_mode:
        ranked` send `poll_option_ids` ordered by preference (top choice
        first). Returns the full updated `poll` block (same shape as
        `GET /api/v1/feeds/{id}` → `poll`), including per-option vote counts,
        percentages, and `total_votes` so clients can swap the embedded poll
        on a feed card without a follow-up fetch.

        For `voting_mode: ranked`, each option additionally carries
        `rank_distribution` (an array of counts — voters who ranked it #1,
        #2, …), `score` (Borda points; higher = stronger preference), and
        `my_rank` (the caller's 1-based rank for that option). The `options`
        array is returned in standing order (highest `score` first), and
        `votes`/`percent` report each option's FIRST-preference tally.

        Result visibility (PRD 05 FR-05-06 / FR-05-10) mirrors the web
        surface — `results_visible` reports whether the breakdown is
        unlocked for this caller. When `false`, `total_votes` and each
        option's `votes`/`percent` (and `rank_distribution`/`score` for
        ranked) are returned as `null`. The vote response itself always sees
        results in `live` mode (the caller just became a voter);
        `hidden_until_close` keeps the breakdown gated until the poll closes
        for non-author/non-admin callers.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                poll_option_id:
                  type: integer
                  description: For `single` voting_mode polls.
                poll_option_ids:
                  type: array
                  items:
                    type: integer
                  description: For `multi` voting_mode polls (unordered) and `ranked`
                    voting_mode polls (ordered by preference, top choice first).
      responses:
        '200':
          description: Vote already recorded (idempotent replay)
          content:
            application/json:
              schema:
                "$ref": "#/paths/~1feeds~1{id}~1poll_votes/post/responses/201/content/application~1json/schema"
        '201':
          description: Vote recorded
          content:
            application/json:
              schema:
                type: object
                required:
                - already_voted
                - poll
                properties:
                  already_voted:
                    type: boolean
                  poll:
                    type: object
                    description: |
                      Poll state embedded on Poll-type feeds. Returned on both list rows
                      and the detail endpoint. Option labels (`options[].id/text/position`)
                      are included on both so cards can render the option list from the
                      list response; per-option vote counts (`options[].votes`) are
                      detail-only to keep list payloads light.
                    properties:
                      closes_at:
                        type: string
                        format: date-time
                        nullable: true
                      voting_mode:
                        type: string
                        nullable: true
                        enum:
                        - single
                        - multi
                        - ranked
                        description: |
                          Controls how `my_vote_option_ids` should be interpreted:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order is not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                      is_anonymous:
                        type: boolean
                        nullable: true
                      result_visibility:
                        type: string
                        nullable: true
                        enum:
                        - live
                        - hidden_until_close
                      allow_change_vote:
                        type: boolean
                        nullable: true
                      can_vote:
                        type: boolean
                        description: |
                          Whether POST (a first vote) or PATCH (a change) on
                          `/feeds/{id}/poll_votes` would take this caller's ballot now — the
                          door's own rule: never through an impersonation token or under
                          View-as, on a draft, a closed poll, a vote that may not change, or
                          for a reader outside the audience. Draw the ballot only when true.
                      allow_comments:
                        type: boolean
                        description: |
                          Whether a comment may be posted on the poll right now
                          (`Feed#comments_allowed?`). Folds the author's create-time
                          comment setting (the feed-level `comments_enabled` column) together
                          with the moderation discussion-close state, so poll cards gate their
                          comment input from one field. Set it at create/update time via
                          `poll_config_attributes[allow_comments]` (or top-level
                          `feed[allow_comments]`).
                      is_closed:
                        type: boolean
                        description: True when poll_config.closes_at has elapsed.
                      option_count:
                        type: integer
                        minimum: 0
                        description: Number of poll options.
                      my_vote_option_ids:
                        type: array
                        description: |
                          The caller's voted option_ids. Always present; empty array when
                          the caller has not voted. Interpret via `voting_mode`:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                        items:
                          type: integer
                      results_visible:
                        type: boolean
                        description: |
                          Detail-only. True when the caller is allowed to see the
                          per-option vote breakdown; false when results are gated.

                          Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
                          surface:
                            * Closed poll → true (everyone)
                            * Author or business admin → true (always)
                            * Open + `result_visibility: hidden_until_close` → false
                            * Open + `result_visibility: live`, caller has voted → true
                            * Open + `result_visibility: live`, caller has not voted → false

                          When false, `total_votes` and each `options[].votes` /
                          `options[].percent` are returned as `null` so clients can render
                          a "results hidden" state without inferring whether anyone has
                          voted yet.
                      voters_visible:
                        type: boolean
                        description: |
                          True when `GET /feeds/{id}/poll_option_voters` answers this caller
                          — draw "See who voted" exactly then. Always false on an anonymous
                          poll (`is_anonymous`); on a named poll it follows the
                          `results_visible` rules above. The same rule as the web and phone
                          "See who voted" and the privacy sentence under every poll.
                      total_votes:
                        type: integer
                        minimum: 0
                        nullable: true
                        description: |
                          Aggregate count of distinct voters (sum of active PollVote rows).
                          Detail-only — returned on `GET /feeds/{id}` and on the
                          `/feeds/{id}/poll_votes` responses, omitted from list rows.
                          `null` when `results_visible` is false.
                      options:
                        type: array
                        description: |
                          Poll options. `id`, `text`, and `position` are returned on both
                          list rows and the detail endpoint. `votes` (per-option count)
                          and `percent` (0–100, rounded) are detail-only — list rows omit
                          them. Both are `null` when `results_visible` is false.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            text:
                              type: string
                            position:
                              type: integer
                            votes:
                              type: integer
                              nullable: true
                              description: Detail endpoint only. `null` when results
                                are gated.
                            percent:
                              type: integer
                              minimum: 0
                              maximum: 100
                              nullable: true
                              description: |
                                Detail endpoint only. Share of `total_votes` cast for this
                                option, rounded to the nearest integer (0–100). `null` when
                                results are gated. Matches the value rendered by the web
                                `_poll` partial so mobile and web stay in lockstep.
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: Not a poll, poll closed, or invalid option selection
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Change a previously cast poll vote
      description: |
        Changes the caller's vote on a poll. Gated on
        `poll_config.allow_change_vote` — returns `422` if the poll forbids
        vote changes or the caller has not yet voted. Atomic: the previous
        active vote row is superseded and the new one is created in a single
        transaction. The voter total is unchanged; only the per-option split
        shifts. Returns the same payload shape as POST — including
        `results_visible`, per-option `percent`, and `total_votes` under the
        same gating rules.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                poll_option_id:
                  type: integer
                  description: For `single` voting_mode polls.
                poll_option_ids:
                  type: array
                  items:
                    type: integer
                  description: For `multi` voting_mode polls (unordered) and `ranked`
                    voting_mode polls (ordered by preference, top choice first).
      responses:
        '200':
          description: Vote changed
          content:
            application/json:
              schema:
                type: object
                required:
                - already_voted
                - poll
                properties:
                  already_voted:
                    type: boolean
                  poll:
                    type: object
                    description: |
                      Poll state embedded on Poll-type feeds. Returned on both list rows
                      and the detail endpoint. Option labels (`options[].id/text/position`)
                      are included on both so cards can render the option list from the
                      list response; per-option vote counts (`options[].votes`) are
                      detail-only to keep list payloads light.
                    properties:
                      closes_at:
                        type: string
                        format: date-time
                        nullable: true
                      voting_mode:
                        type: string
                        nullable: true
                        enum:
                        - single
                        - multi
                        - ranked
                        description: |
                          Controls how `my_vote_option_ids` should be interpreted:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order is not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                      is_anonymous:
                        type: boolean
                        nullable: true
                      result_visibility:
                        type: string
                        nullable: true
                        enum:
                        - live
                        - hidden_until_close
                      allow_change_vote:
                        type: boolean
                        nullable: true
                      can_vote:
                        type: boolean
                        description: |
                          Whether POST (a first vote) or PATCH (a change) on
                          `/feeds/{id}/poll_votes` would take this caller's ballot now — the
                          door's own rule: never through an impersonation token or under
                          View-as, on a draft, a closed poll, a vote that may not change, or
                          for a reader outside the audience. Draw the ballot only when true.
                      allow_comments:
                        type: boolean
                        description: |
                          Whether a comment may be posted on the poll right now
                          (`Feed#comments_allowed?`). Folds the author's create-time
                          comment setting (the feed-level `comments_enabled` column) together
                          with the moderation discussion-close state, so poll cards gate their
                          comment input from one field. Set it at create/update time via
                          `poll_config_attributes[allow_comments]` (or top-level
                          `feed[allow_comments]`).
                      is_closed:
                        type: boolean
                        description: True when poll_config.closes_at has elapsed.
                      option_count:
                        type: integer
                        minimum: 0
                        description: Number of poll options.
                      my_vote_option_ids:
                        type: array
                        description: |
                          The caller's voted option_ids. Always present; empty array when
                          the caller has not voted. Interpret via `voting_mode`:
                            * `single` — at most 1 element
                            * `multi`  — 0..N elements, order not meaningful
                            * `ranked` — 0..N elements, ordered by rank (most-preferred first)
                        items:
                          type: integer
                      results_visible:
                        type: boolean
                        description: |
                          Detail-only. True when the caller is allowed to see the
                          per-option vote breakdown; false when results are gated.

                          Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
                          surface:
                            * Closed poll → true (everyone)
                            * Author or business admin → true (always)
                            * Open + `result_visibility: hidden_until_close` → false
                            * Open + `result_visibility: live`, caller has voted → true
                            * Open + `result_visibility: live`, caller has not voted → false

                          When false, `total_votes` and each `options[].votes` /
                          `options[].percent` are returned as `null` so clients can render
                          a "results hidden" state without inferring whether anyone has
                          voted yet.
                      voters_visible:
                        type: boolean
                        description: |
                          True when `GET /feeds/{id}/poll_option_voters` answers this caller
                          — draw "See who voted" exactly then. Always false on an anonymous
                          poll (`is_anonymous`); on a named poll it follows the
                          `results_visible` rules above. The same rule as the web and phone
                          "See who voted" and the privacy sentence under every poll.
                      total_votes:
                        type: integer
                        minimum: 0
                        nullable: true
                        description: |
                          Aggregate count of distinct voters (sum of active PollVote rows).
                          Detail-only — returned on `GET /feeds/{id}` and on the
                          `/feeds/{id}/poll_votes` responses, omitted from list rows.
                          `null` when `results_visible` is false.
                      options:
                        type: array
                        description: |
                          Poll options. `id`, `text`, and `position` are returned on both
                          list rows and the detail endpoint. `votes` (per-option count)
                          and `percent` (0–100, rounded) are detail-only — list rows omit
                          them. Both are `null` when `results_visible` is false.
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            text:
                              type: string
                            position:
                              type: integer
                            votes:
                              type: integer
                              nullable: true
                              description: Detail endpoint only. `null` when results
                                are gated.
                            percent:
                              type: integer
                              minimum: 0
                              maximum: 100
                              nullable: true
                              description: |
                                Detail endpoint only. Share of `total_votes` cast for this
                                option, rounded to the nearest integer (0–100). `null` when
                                results are gated. Matches the value rendered by the web
                                `_poll` partial so mobile and web stay in lockstep.
        '401':
          description: Unauthorized
        '403':
          description: Caller not in the feed's audience
        '404':
          description: Feed not found
        '422':
          description: Not a poll, poll closed, change-vote disabled, no previous
            vote, or invalid option selection
  "/feeds/{id}/poll_option_voters":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List users who voted for a single poll option
      description: |
        Drill-down roster for one poll option — the users who picked it.
        A single endpoint serves both poll shapes:

          * `single` / `multi` voting modes → a flat `users` list.
          * `ranked` voting mode → `grouped` buckets, one per rank position
            the voters gave this option (rank 1 first), so a client can
            render the per-rank accordion.

        Each row carries the canonical v1 user shape (id / name / avatar_url)
        plus the row subtitle (job title / office location) — the same shape
        as `GET /feeds/{id}/viewers`. The voter set and per-voter rank are
        read at request time from the active poll votes.

        Authorization layers on top of the feed audience gate:
          * Anonymous polls (`is_anonymous = true`) never expose voters (403).
          * Otherwise the same visibility decider as the embedded poll block
            applies — an open `live` poll the caller has not voted in, or an
            open `hidden_until_close` poll viewed by a non-author/admin,
            returns 403.

        The `users` / `grouped` lists stop at 200 voters per option; `total`
        counts the option's whole roster. The poll block's `voters_visible`
        says whether this endpoint answers the caller.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: option_id
        in: query
        required: true
        description: Poll option whose voters to list. Must belong to this feed's
          poll.
        schema:
          type: integer
      responses:
        '200':
          description: |
            Voter roster for the option. `users` is present for single/multi
            polls; `grouped` is present for ranked polls.
          content:
            application/json:
              schema:
                type: object
                required:
                - poll_option_id
                - voting_mode
                - total
                properties:
                  poll_option_id:
                    type: integer
                  voting_mode:
                    type: string
                    enum:
                    - single
                    - multi
                    - ranked
                  total:
                    type: integer
                    description: Number of voters returned (capped at 200).
                  users:
                    type: array
                    description: Flat voter list — present for single/multi polls.
                    items:
                      type: object
                      required:
                      - id
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                          nullable: true
                        title:
                          type: string
                          nullable: true
                          description: Voter's job title (row subtitle).
                        location:
                          type: string
                          nullable: true
                          description: Voter's office location (row subtitle).
                  grouped:
                    type: array
                    description: Per-rank buckets (rank 1 first) — present for ranked
                      polls.
                    items:
                      type: object
                      required:
                      - rank
                      - count
                      - users
                      properties:
                        rank:
                          type: integer
                          description: 1-based rank these voters gave the option.
                        count:
                          type: integer
                        users:
                          type: array
                          items:
                            type: object
                            required:
                            - id
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                                nullable: true
                              avatar_url:
                                type: string
                                nullable: true
                              title:
                                type: string
                                nullable: true
                              location:
                                type: string
                                nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Anonymous poll
          or results not yet visible to the caller:
        '404':
          description: Feed or poll option not found
        '422':
          description: Feed is not a poll
  "/news-feed/audiences":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List composer audiences (mobile)
      description: |
        Returns the audiences the caller may broadcast to from the composer.
        When someone reports to the caller, the list begins with the virtual "My Direct Reports" segment,
        followed by every `NotificationRecipientGroup` the caller is allowed
        to target under the active rule:

          * Admin / News Feed app admin → every manageable group
          * Mode B (app-access rules configured) → groups in rules ∩ caller's groups
          * Mode A (no rules)                    → caller's groups only

        Each entry carries an approximate `member_count` so the picker can
        render "Engineering (42)". `member_count` is `null` when the group
        needs runtime context (location, shift, radius) that the bare lookup
        can't supply — clients should hide the count in that case.
      responses:
        '200':
          description: Allowed audiences for the composer
          content:
            application/json:
              schema:
                type: object
                required:
                - audiences
                properties:
                  audiences:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - kind
                      properties:
                        id:
                          oneOf:
                          - type: string
                            example: direct_reports
                          - type: string
                            example: '17'
                          description: |
                            `'direct_reports'` for the virtual segment, otherwise
                            the stringified NotificationRecipientGroup id.
                        name:
                          type: string
                          example: Engineering
                        description:
                          type: string
                          nullable: true
                          example: All engineering staff in the org
                          description: |
                            Human-readable description of the audience.
                            `null` for groups that don't have one configured.
                            The virtual `direct_reports` segment carries a
                            fixed sentence describing the relationship.
                        kind:
                          type: string
                          enum:
                          - virtual
                          - group
                        member_count:
                          type: integer
                          nullable: true
                          description: Approximate count; `null` when the group needs
                            runtime context.
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/news-feed/people":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: People a post can be aimed at (composer "Pick people")
      description: |
        The composer's "Pick people" list for a post aimed at specific people.
        Send the picked `id`s back as `feed[extra_user_ids]` on `POST /feeds`.

        Needs only the Communications app (and `read:news_feed`) — not the
        Performance app's feedback picker, which earlier clients paged. The
        list is the web composer's people picker: active members of this
        company, no service accounts, the caller included (`is_self: true`).
        `q` matches names, and email, job title, office, phone or employee id only
        where the People directory shows that field to the caller; `email`
        and `meta` are null/blank where it does not.

        Paged in a fixed order, so no person repeats or goes missing across
        pages: read `meta.has_next_page`.

        Composer-gated: a caller who can neither compose nor post a must-read
        gets 403.
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Search term. Blank lists everyone, alphabetically.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: One page of people
          content:
            application/json:
              schema:
                type: object
                required:
                - items
                - meta
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - name
                      - is_self
                      properties:
                        id:
                          type: integer
                          example: 4521
                        name:
                          type: string
                          example: Dana Ortiz
                        email:
                          type: string
                          nullable: true
                          description: Null where the directory hides email from the
                            caller.
                        meta:
                          type: string
                          nullable: true
                          example: Shift Lead · Denver
                          description: What the directory shows the caller of this
                            person (title, office, email).
                        detail:
                          type: string
                          nullable: true
                          description: The second line that tells namesakes apart.
                        is_self:
                          type: boolean
                          description: True on the caller's own row.
                        avatar_url:
                          type: string
                          format: uri
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                  total_count:
                    type: integer
                  unread_notification_count:
                    type: integer
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          description: |
            Communications is off or closed to the caller
            (`CommunicationsUnavailable`), the token lacks `read:news_feed`, or
            the caller cannot compose.
  "/news-feed/topics":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List topic taxonomy
      description: |
        Returns the News Feed topic taxonomy for the caller's business.
        Used by the feed filter chip strip and the composer topic picker.
        Names are normalized to lowercase-hyphen slug form.
      parameters:
      - name: managed
        in: query
        description: When true, restrict to admin-curated topics.
        schema:
          type: boolean
      - name: status
        in: query
        description: Lifecycle filter. Defaults to `active`.
        schema:
          type: string
          enum:
          - active
          - disabled
          default: active
      - name: q
        in: query
        description: Prefix-match against the normalized name (e.g. `safe` matches
          `safety-update`).
        schema:
          type: string
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
      responses:
        '200':
          description: Topic list
          content:
            application/json:
              schema:
                type: object
                required:
                - topics
                - meta
                properties:
                  topics:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          example: benefits
                        status:
                          type: string
                          enum:
                          - active
                          - disabled
                        managed:
                          type: boolean
                        following:
                          type: boolean
                          description: Whether the caller follows this topic (followed
                            topics lead their feed).
                  meta:
                    type: object
                    properties:
                      count:
                        type: integer
                      per_page:
                        type: integer
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/news-feed/topics/{id}/follow":
    parameters:
    - name: id
      in: path
      required: true
      description: The topic's id (any topic of the caller's business, active or retired).
      schema:
        type: integer
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Follow a topic
      description: |
        Follows the topic for the caller. Idempotent — following a followed
        topic changes nothing. Requires the `write:news_feed` scope. The same
        writer serves the web and phone Follow control (`NewsFeed::TopicFollow.follow!`).
      responses:
        '200':
          description: The topic, now followed
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedTopicFollowResponse"
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: Topic not found
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Unfollow a topic
      description: 'Stops following the topic. Idempotent. Requires the `write:news_feed`
        scope.

        '
      responses:
        '200':
          description: The topic, no longer followed
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedTopicFollowResponse"
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: Topic not found
  "/news-feed/my-posts":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List the caller's own posts (My Posts)
      description: |
        The mobile "My Posts" screen — the calling user's OWN communications
        across BOTH stores (feed posts and broadcasts), grouped by lifecycle.
        Faithfully mirrors the web `Apps::NewsFeedController#my_posts`: same two
        stores, same lifecycle scopes, same `updated_at DESC` merge ordering,
        and the same acknowledgement-progress source.

        Filters (`filter` query param, mutually exclusive):
          * `drafts`    — not sent yet (`status=draft`), minus the composer's
                          body-less scaffolding posts and anything at an
                          approval gate — the web Drafts tab; its posts are
                          the phone's My drafts. Alias: `draft`.
          * `scheduled` — queued to publish (`status=scheduled`), EXCLUDING
                          anything currently held at an approval gate.
          * `approval`  — items sitting at a pending approval gate (a pending
                          `CommsHub::ApprovalRequest`), whatever the item's own
                          status. Alias: `awaiting_approval`.
          * `sent`      — already out: feeds `published`/`expired`, broadcasts
                          `published`/`archived`. Alias: `published`.

        Each row is projected into one kind-agnostic card shape whether it is a
        feed or a broadcast (`kind`). Acknowledgement progress
        (`acknowledged_count` / `recipient_count` / `acknowledgement_percent`)
        is populated only on the `sent` filter and only for posts that gate on
        acknowledgement (must-read feeds and ack-required broadcasts); it is
        `null` on every other row — matching exactly where the web renders the
        ack bar.

        Each row also says what the web row says: on `approval`, who it waits
        on (`waiting_on_label`, `waiting_phrase`); on `drafts`, who turned it
        down and why (`not_approved`); on `sent`, how many people it went to
        (`published_to_count`) and read it (`read_count`). Each is `null` on
        rows it does not describe.

        `meta.filter_counts` carries the per-filter totals (drafts / scheduled /
        approval / sent) so the client can render the tab badges without extra
        round-trips. They count the lifecycle, not the search: `q` and `type`
        narrow the list and `meta.total`, never the badges — as on the web.

        `q` and `type` are the web page's search box and Type chips, composed
        with `filter`: for the same author, words and type the endpoint returns
        the same rows and count as web My Posts.
      parameters:
      - name: filter
        in: query
        description: |
          Lifecycle filter; defaults to `sent`, matching the web My Posts
          screen. Unrecognized values fall back to `sent`.
        schema:
          type: string
          enum:
          - drafts
          - draft
          - scheduled
          - approval
          - awaiting_approval
          - sent
          - published
          default: sent
      - name: q
        in: query
        description: |
          Search text, matched anywhere (case-insensitive) in a post's body
          or a broadcast's title and description. At least 2 characters —
          a shorter one answers 422 `query_too_short`. Blank means no search.
        schema:
          type: string
          minLength: 2
      - name: type
        in: query
        description: |
          The web Type chip: a message kind (`post`, `announcement`,
          `must_read`, `break_through` — a Broadcast — or `in_context` — Spot
          only), or `all` (the default). `broadcast` is the old value for
          `break_through`. Anything else answers 422 `invalid_type`.
          `meta.types` lists the chips with their labels.
        schema:
          type: string
          enum:
          - all
          - post
          - announcement
          - must_read
          - break_through
          - in_context
          - broadcast
          default: all
      - name: page
        in: query
        description: 1-indexed page number.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: The caller's posts for the requested filter
          content:
            application/json:
              schema:
                type: object
                required:
                - posts
                - meta
                properties:
                  posts:
                    type: array
                    items:
                      type: object
                      required:
                      - id
                      - kind
                      - type
                      - type_label
                      - status
                      - status_label
                      - must_read
                      - title
                      - author
                      properties:
                        id:
                          type: integer
                        kind:
                          type: string
                          enum:
                          - feed
                          - broadcast
                        type:
                          type: string
                          enum:
                          - update
                          - question
                          - poll
                          - broadcast
                          description: Machine value — feed `content_type`, or `broadcast`.
                        type_label:
                          type: string
                          example: Update
                          description: Display label for the type chip (Update / Question
                            / Poll / Broadcast).
                        status:
                          type: string
                          enum:
                          - draft
                          - scheduled
                          - published
                          - expired
                          - archived
                          description: The item's own persisted status.
                        status_label:
                          type: string
                          example: Awaiting approval
                          description: |
                            Chip label — the humanized status, or
                            `Awaiting approval` on the `approval` filter.
                        must_read:
                          type: boolean
                          description: True for must-read feeds (broadcasts are always
                            false).
                        is_critical:
                          type: boolean
                          description: True for critical broadcasts (feeds are always
                            false).
                        requires_acknowledgement:
                          type: boolean
                          description: Whether the post gates on acknowledgement.
                        title:
                          type: string
                          description: Broadcast title, or the feed's derived headline.
                        summary:
                          type: string
                          nullable: true
                          description: Stripped, ≤220-char one-liner shown on the
                            card. `null` when the body is empty.
                        body:
                          type: string
                          nullable: true
                          description: The post's body as written (Markdown) / the
                            broadcast's description. A post stored as HTML (a newsletter's
                            in-app copy) is written out as Markdown, with its pictures
                            at addresses signed for an hour — the same `body` GET
                            /feeds serves.
                        newsletter:
                          allOf:
                          - "$ref": "#/components/schemas/NewsFeedNewsletterCopy"
                          nullable: true
                          description: A newsletter's in-app copy cut at its hero
                            banners — the same `newsletter` GET /feeds serves; null
                            on every other row.
                        scheduled_at:
                          type: string
                          format: date-time
                          nullable: true
                        published_at:
                          type: string
                          format: date-time
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        updated_at:
                          type: string
                          format: date-time
                        acknowledged_count:
                          type: integer
                          nullable: true
                          description: |
                            Distinct acknowledgers. Populated only on `filter=sent`
                            for ack-gated posts; `null` otherwise.
                        recipient_count:
                          type: integer
                          nullable: true
                          description: Addressable audience size (the ack denominator).
                            `null` unless ack-gated on `sent`.
                        left_count:
                          type: integer
                          nullable: true
                          description: |
                            Of the people still to acknowledge, those who left the company first — they
                            never can. The post is done once `acknowledged_count + left_count` reaches
                            `recipient_count` (show "N left before acknowledging"). `null` unless ack-gated on `sent`.
                        acknowledgement_percent:
                          type: integer
                          nullable: true
                          description: Rounded `acknowledged_count / recipient_count`
                            percentage — the bar's width. `null` unless ack-gated
                            on `sent`.
                        acknowledgement_percent_label:
                          type: string
                          nullable: true
                          example: "<1%"
                          description: |
                            The percentage as words to draw beside the counts: whole numbers, except a
                            non-zero count that rounds to 0 reads `<1%` and a count short of the
                            audience that rounds to 100 reads `>99%` — so `1/500` never reads `0%`.
                            `null` exactly when `acknowledgement_percent` is.
                        names_hidden:
                          type: object
                          description: |
                            Present only on a row whose works-council group (General
                            Settings › Countries) is under the company's smallest reportable
                            group: `acknowledged_count`, `recipient_count` and
                            `acknowledgement_percent` then count only the named people, and
                            the group is not counted — `count` and `group_count` are null and
                            `fewer_than` is that group, "a few" (the shape of `BroadcastNamesHidden`).
                          properties:
                            count:
                              type: integer
                              nullable: true
                            group_count:
                              type: integer
                              nullable: true
                              description: 'Null since #4546: a hidden group under
                                the company''s smallest group is "a few" (`fewer_than`).
                                Kept for older clients.'
                            fewer_than:
                              type: integer
                              description: 'Present only when the people it leaves
                                out (or the hidden group) are fewer than the company''s
                                smallest group: that number — they are "a few", and
                                `count` / `group_count` are null (#4546).'
                            reason:
                              type: string
                              example: Names are hidden for people in Germany (works
                                council).
                        published_to_count:
                          type: integer
                          nullable: true
                          description: |
                            How many people a sent post went to — the acknowledgement audience for a post
                            that asks for it, else the audience the engagement figures count. `null` off
                            `filter=sent`, and before a post's first engagement refresh (~15 min).
                        read_count:
                          type: integer
                          nullable: true
                          description: |
                            Of `published_to_count`, how many read it (the web row's "Read by N of M") — for a
                            sent post that asked nobody to acknowledge it. `null` otherwise.
                        approval_request_id:
                          type: integer
                          nullable: true
                          description: The pending approval request behind an `approval`
                            row — the id POST `/approvals/{id}/withdraw` takes. `null`
                            on other rows.
                        waiting_on_label:
                          type: string
                          nullable: true
                          example: anyone with the manager role
                          description: Who it waits on, in the web row's words ("Waiting
                            on …"). `null` off `approval`.
                        waiting_phrase:
                          type: string
                          nullable: true
                          example: for 2 days
                          description: How long it has waited ("for 2 days", "for
                            3 hours past its deadline"). `null` off `approval`.
                        not_approved:
                          type: object
                          nullable: true
                          description: A draft whose latest review turned it down
                            — who, and the reason they gave. `null` otherwise.
                          properties:
                            approval_request_id:
                              type: integer
                            by:
                              type: string
                              nullable: true
                              description: The reviewer's name.
                            notes:
                              type: string
                              nullable: true
                        topics:
                          type: array
                          description: Topic chips (feeds only, ≤3); empty for broadcasts.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                        author:
                          type: object
                          description: Always the calling user.
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            avatar_url:
                              type: string
                  meta:
                    type: object
                    required:
                    - filter
                    - page
                    - per_page
                    - total_pages
                    - total_count
                    - filter_counts
                    - q
                    - type
                    - total
                    - types
                    properties:
                      filter:
                        type: string
                        enum:
                        - drafts
                        - scheduled
                        - approval
                        - sent
                      page:
                        type: integer
                      per_page:
                        type: integer
                      total_pages:
                        type: integer
                      total_count:
                        type: integer
                      filter_counts:
                        type: object
                        description: Per-filter totals for the tab badges — the lifecycle's,
                          before `q` and `type`.
                        properties:
                          drafts:
                            type: integer
                          scheduled:
                            type: integer
                          approval:
                            type: integer
                          sent:
                            type: integer
                      q:
                        type: string
                        nullable: true
                        description: The search applied, trimmed; `null` when none.
                      type:
                        type: string
                        enum:
                        - all
                        - post
                        - announcement
                        - must_read
                        - break_through
                        - in_context
                        description: The Type applied (`broadcast` is reported as
                          `break_through`).
                      total:
                        type: integer
                        description: How many posts match the filter, search and type
                          — the web's result count. Equal to `total_count`.
                      types:
                        type: array
                        description: The Type chips in the web's order, with their
                          labels.
                        items:
                          type: object
                          properties:
                            key:
                              type: string
                              example: must_read
                            label:
                              type: string
                              example: Must-read
        '401':
          description: Unauthorized
        '403':
          description: Communications not enabled or not accessible for this business
        '422':
          description: |
            `query_too_short` — `q` is under 2 characters (`details.min_length`);
            `invalid_type` — `type` names no Type (`details.allowed`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        enum:
                        - query_too_short
                        - invalid_type
                      message:
                        type: string
                      details:
                        type: object
  "/news-feed/team-must-reads":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: My team's must-reads (who on the caller's team still owes an acknowledgement)
      description: |
        The phone page "My team's must-reads" for the native apps
        (`Comms::TeamMustReads`): who on the caller's team still owes an
        acknowledgement on a must-read post or an acknowledgement broadcast,
        most overdue first, people first. The team is this business's reporting
        line. `reach=all` (everyone below the caller) is a communicator's view —
        a company admin, a Communications app admin, a manager or above, or
        someone in a leadership position (`reach_all_allowed`); anyone else
        asking for it gets their direct reports, as on the page. Under a works
        council the hidden people are left off `people` and reported the way
        the other status lists report them (never a small group's exact size).
        Someone with no team gets 200 with `has_team: false` and a message.
        Read-only.
      parameters:
      - name: reach
        in: query
        schema:
          type: string
          enum:
          - direct
          - all
          default: direct
      - name: status
        in: query
        schema:
          type: string
          enum:
          - outstanding
          - overdue
          default: outstanding
      - name: limit
        in: query
        description: People listed (default 100, at most 200).
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 100
      responses:
        '200':
          description: The team's owed must-reads, or has_team false.
          content:
            application/json:
              schema:
                type: object
                properties:
                  has_team:
                    type: boolean
                  message:
                    type: string
                    description: Only when has_team is false.
                  reach:
                    type: string
                    enum:
                    - direct
                    - all
                  reach_all_allowed:
                    type: boolean
                  status:
                    type: string
                    enum:
                    - outstanding
                    - overdue
                  team_size:
                    type: integer
                  people_owing:
                    type: integer
                  outstanding:
                    type: integer
                  overdue:
                    type: integer
                  truncated:
                    type: boolean
                    description: More people owe than `limit` lists.
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        user:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                        pending:
                          type: integer
                        overdue:
                          type: integer
                        items:
                          type: array
                          items:
                            type: object
                            properties:
                              type:
                                type: string
                                enum:
                                - NewsFeed::Feed
                                - Broadcast
                              id:
                                type: integer
                              kind:
                                type: string
                                example: Must-read
                              title:
                                type: string
                              due_at:
                                type: string
                                format: date-time
                                nullable: true
                              overdue:
                                type: boolean
                  names_hidden:
                    type: object
                    description: |
                      Present only when a works council hides names (General
                      Settings › Countries): the people owing who are left out of
                      `people` — counted, never named.
                      While the team's hidden group is under the company's smallest
                      reportable group, `count` and `group_count` are null and
                      `fewer_than` is that group, "a few" (the figures beside it
                      count only the named people) — the shape of `BroadcastNamesHidden`.
                    properties:
                      count:
                        type: integer
                        nullable: true
                      group_count:
                        type: integer
                        nullable: true
                      fewer_than:
                        type: integer
                        description: 'Present only when the people it leaves out (or
                          the hidden group) are fewer than the company''s smallest
                          group: that number — they are "a few", and `count` / `group_count`
                          are null (#4546).'
                      reason:
                        type: string
                        example: Names are hidden for people in Germany (works council).
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/news-feed/settings":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Effective News Feed configuration + caller capabilities
      description: |
        Returns the merged News Feed configuration (marketplace schema
        defaults ← business override ← admin flag override) plus a
        `capabilities` block derived from `NewsFeed::Permissions` and the
        `broadcast_channels` the caller may send a broadcast on. Mobile
        clients use this to decide which composer tabs to show, whether
        the must-read toggle is available, which delivery channels to
        draw, etc., without having to re-implement role rules.

        Read-only and callable by every authenticated user. The admin
        write surface lives at `/api/v1/admin/news-feed/settings`.
      responses:
        '200':
          description: Effective settings + caller capabilities
          content:
            application/json:
              schema:
                type: object
                required:
                - settings
                - capabilities
                - broadcast_channels
                properties:
                  settings:
                    type: object
                    additionalProperties: true
                    description: |
                      Free-form bag — keys come from the marketplace app's
                      configuration_schema. Common keys include
                      `composer_update_type`, `composer_question_type`,
                      `composer_poll_type`, `audience_post_to_everyone`,
                      `must_read_permission`.
                  capabilities:
                    type: object
                    properties:
                      can_compose_any:
                        type: boolean
                      can_post_update:
                        type: boolean
                      can_post_question:
                        type: boolean
                      can_post_poll:
                        type: boolean
                      can_post_must_read:
                        type: boolean
                      can_post_to_everyone:
                        type: boolean
                      can_post_announcement:
                        type: boolean
                        description: |
                          Whether the caller may send the Announcement kind of
                          the unified Communications composer. Rides the same
                          communicator grant as Must-read (the two differ in
                          what the READER owes, not in who may send), so it
                          tracks `can_post_must_read`.
                      can_post_broadcast:
                        type: boolean
                        description: |
                          Whether the caller may send the Broadcast kind. A
                          DIFFERENT grant from the feed kinds: the broadcast
                          engine must be entitled for the tenant (the
                          `broadcast` OR `communications` app) AND the caller
                          must hold `create` on broadcasts. This is what lets
                          the compose menu express "may announce but may not
                          broadcast" — deriving it from `can_post_must_read`
                          offers a send the server refuses.
                  compose_menu:
                    type: object
                    description: |
                      The web's "New communication" menu: the kinds this
                      person may send, named by intent, and their saved
                      presets — only the kinds this token could send.
                    properties:
                      kinds:
                        type: array
                        items:
                          type: object
                          properties:
                            kind:
                              type: string
                            label:
                              type: string
                            type_label:
                              type: string
                            description:
                              type: string
                      presets:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            kind:
                              type: string
                            type_label:
                              type: string
                            summary:
                              type: string
                      emergency_alert:
                        type: boolean
                        description: |
                          Offer the menu's separate Emergency alert entry. Never
                          one of `kinds`, so nobody reaches an alert by
                          accident. True only for someone the alert page lets
                          in and lets send (Safety Hub open to them, its alerts
                          module on, a manager or the app's admin, and allowed
                          to dispatch), on a token holding `write:broadcasts` —
                          the scope `POST /alerts` asks.
                  broadcast_channels:
                    type: array
                    description: |
                      The channels a broadcast can be sent on in this tenant —
                      the Channels card of the web composer's Break-through
                      panel, answered as data so a client draws the same
                      controls instead of hardcoding a set that can disagree
                      with the tenant. Listed in composer order.

                      Same conditions as that page:

                      * `in_app` is always on and has no toggle
                        (`selectable: false`, no `param`).
                      * `email` / `sms` / `voice` post back inside
                        `broadcast[channels][]` on `POST /api/v1/broadcasts`.
                      * `signage` ("Break-room screens") is listed ONLY when
                        Digital Signage is entitled for the tenant AND at least
                        one active screen is registered — an entitled app with
                        no live screen is a control that cannot deliver, so the
                        entry is absent rather than present-and-useless.

                      Always present, for every caller — this describes the
                      TENANT, not the caller. WHO may broadcast is the separate
                      `capabilities.can_post_broadcast` flag: gate the compose
                      entry point on that, then use this list to draw the
                      controls. The array is never empty (`in_app` always
                      delivers).

                      A channel the company switched off (Knowledge Layer P5)
                      is still listed, with `off_for_company: true` and its
                      `hint` saying so — it sends a non-Critical broadcast
                      nothing, so draw it as off rather than offer it as if it
                      went.
                    items:
                      type: object
                      required:
                      - key
                      - label
                      - selectable
                      - default_selected
                      properties:
                        key:
                          type: string
                          enum:
                          - in_app
                          - email
                          - sms
                          - voice
                          - signage
                        label:
                          type: string
                          description: Composer wording, e.g. `Voice call`, `Break-room
                            screens`.
                        selectable:
                          type: boolean
                          description: |
                            `false` only for `in_app`, which always delivers and
                            cannot be switched off.
                        default_selected:
                          type: boolean
                          description: |
                            The composer's pre-ticked state (Email on, SMS and
                            Voice off). A COMPOSER default, not a server one:
                            `POST /broadcasts` reads an ABSENT `channels` key as
                            "the author didn't touch channels" and leaves every
                            channel on, so a client rendering these controls
                            must send the full array it ends up with.
                        param:
                          type: string
                          nullable: true
                          description: |
                            Where this channel's value goes in the
                            `POST /api/v1/broadcasts` body: `channels[]` for the
                            per-user delivery channels, `publish_to_signage` for
                            break-room screens (location-bound, so deliberately
                            NOT folded into `channels[]`). `null` for `in_app`,
                            which has nothing to send.
                        hint:
                          type: string
                          nullable: true
                          description: |
                            The composer's tooltip for this channel, when it has
                            one. For a channel that is `off_for_company`, the
                            web composer's own note: the sentence, where an
                            admin turns it back on, and for email and voice that
                            a Critical broadcast still uses it.
                          example: Voice call is off for your company — Broadcast
                            settings. A Critical broadcast still uses it.
                        off_for_company:
                          type: boolean
                          description: |
                            `email` / `sms` / `voice` only. True when the company
                            has switched this channel off (its notification /
                            SMS / Broadcast settings), so a non-Critical broadcast
                            sends nothing on it. A Critical broadcast still goes
                            out by email and voice call; SMS stays off.
                        location_ids_param:
                          type: string
                          description: |
                            `signage` only — `signage_location_ids[]`, the
                            optional site narrowing. Omit or send empty to show
                            on every screen.
                        screen_count:
                          type: integer
                          description: "`signage` only — active screens registered
                            for the tenant."
                        delivery_note:
                          type: string
                          description: |
                            `signage` only. Screens PULL: each shows the post at
                            its next refresh, and only where its own signage
                            admin has Communications posts switched on.
                        locations:
                          type: array
                          description: |
                            `signage` only — the sites that actually have an
                            active screen (sites without one are omitted, since
                            picking them would change nothing).
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/admin/news-feed/settings":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Communications settings, for an admin
      description: |
        Every Communications setting the tenant can change, as stored: a saved
        choice, else the setting's default. The two reader switches
        (`show_ai_assisted_to_readers`, `show_delegate_to_readers`) are
        company-wide and come from their one home, the same values Admin ›
        Content Moderation › Settings shows.

        For a company admin or a Communications app admin. A token also needs
        the `read:settings` scope (`admin` and `read:all` include it); a
        signed-in session is not asked for a scope.
      responses:
        '200':
          description: The settings, keyed by setting name
          content:
            application/json:
              schema:
                type: object
                required:
                - settings
                properties:
                  settings:
                    type: object
                    additionalProperties: true
        '401':
          description: Unauthorized
        '402':
          description: |
            The organization has used its plan's monthly API allowance and has no API credits left
            (error code `api_credits_exhausted`). Answered to a call made with an API token someone
            created (personal, admin, service-account or CLI) until the allowance resets on the 1st,
            credits are bought, or auto top-up buys more; never to the MangoApps apps' own sign-in or a
            signed-in session.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: api_credits_exhausted
                  message: This account has used its plan's API allowance for the
                    month and has no API credits left. Buy platform credits, or turn
                    on auto top-up, under Admin → Billing & Payments → Credits; the
                    allowance resets on the 1st.
                  details:
                    buy_credits: "/admin/billing/purchase_credits?resource_type=api_calls"
        '403':
          description: |
            Not an admin (`{"error": {"code": "forbidden", "message": "Admin access required."}}`), or the token
            lacks the scope (`{"error": {"code": "insufficient_permissions", "message": "Insufficient permissions. Required scope: read:settings"}, "code": "insufficient_permissions"}`).
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Change Communications settings, for an admin
      description: |
        Saves the settings sent, all or nothing, with the checks Communications
        › Settings applies (a choice from the listed options, a number within
        range, a valid reaction list); keys the Communications app does not
        declare are ignored. The two reader switches are written to their
        company-wide home. The Comms Hub module switches and Broadcast's AI and
        channel defaults are set on their own pages and refused here when
        changed. Only what changed is written: a setting sent with the value it
        already has (as `GET` returned it) is left as it is, so sending back the
        whole `settings` object with one value edited saves just that one.

        For a company admin or a Communications app admin. A token also needs
        the `write:settings` scope (`admin` includes it; `read:settings` and
        `read:all` do not); a signed-in session is not asked for a scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - settings
              properties:
                settings:
                  type: object
                  additionalProperties: true
                  example:
                    show_ai_assisted_to_readers: true
      responses:
        '200':
          description: The setting names that were saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: array
                    items:
                      type: string
        '401':
          description: Unauthorized
        '402':
          description: |
            The organization has used its plan's monthly API allowance and has no API credits left
            (error code `api_credits_exhausted`). Answered to a call made with an API token someone
            created (personal, admin, service-account or CLI) until the allowance resets on the 1st,
            credits are bought, or auto top-up buys more; never to the MangoApps apps' own sign-in or a
            signed-in session.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: api_credits_exhausted
                  message: This account has used its plan's API allowance for the
                    month and has no API credits left. Buy platform credits, or turn
                    on auto top-up, under Admin → Billing & Payments → Credits; the
                    allowance resets on the 1st.
                  details:
                    buy_credits: "/admin/billing/purchase_credits?resource_type=api_calls"
        '403':
          description: |
            Not an admin (`{"error": {"code": "forbidden", "message": "Admin access required."}}`), or the token
            lacks the scope (`{"error": {"code": "insufficient_permissions", "message": "Insufficient permissions. Required scope: write:settings"}, "code": "insufficient_permissions"}`).
        '422':
          description: |
            Nothing was saved: a value Communications › Settings would refuse
            (`{"error": {"code": "validation_failed", "message": "Invalid value for signal_threshold_risk — must be at most 1.0."}}`),
            or a changed setting kept on another page (`{"error": {"code": "validation_failed", "message": "enable_calendar: set on Communications › Settings › Modules or Broadcast › Settings, not through this endpoint."}}`).
  "/notification-preferences":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Your Communications notification cadence
      description: |
        How the caller hears about new Communications posts in this business:
        the default preference (`segment_id: null`) first, then any
        per-segment preferences.

        Someone who never saved a preference still gets a default entry, with
        `persisted: false` — the cadence they actually get, so a settings
        screen can say "Announcements: hourly digest" instead of nothing.
        Preferences are per business: a person in two businesses has one set
        in each.

        Must-read posts always notify right away, whatever the cadence.
      responses:
        '200':
          description: The caller's preferences in this business
          content:
            application/json:
              schema:
                type: object
                required:
                - preferences
                properties:
                  preferences:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedNotificationPreference"
        '401':
          description: Unauthorized
        '403':
          description: Communications is not enabled for this business
          or the caller has no access to it:
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Change your Communications notification cadence
      description: |
        Saves the caller's cadence in this business. Leave `segment_id` out for
        the default preference. A field left out keeps its current value; an
        empty `operational_frequency` resets it to the company default.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                segment_id:
                  type: integer
                  nullable: true
                  description: A notification segment of this business; omit for the
                    default preference
                operational_frequency:
                  type: string
                  enum:
                  - realtime
                  - hourly
                  - daily
                  - ''
                  description: Announcements — right away, or an hourly or daily digest.
                    Empty resets to the company default.
                social_frequency:
                  type: string
                  enum:
                  - daily
                  - weekly
      responses:
        '200':
          description: The saved preference
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedNotificationPreference"
        '401':
          description: Unauthorized
        '403':
          description: Communications is not enabled for this business
          or the caller has no access to it:
        '422':
          description: |
            An unknown segment (`{"error": {"code": "validation_failed", "message": "Unknown segment."}}`) or an invalid
            frequency (`{"errors": ["Operational frequency is not included in the list"]}`).
    put:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Change your Communications notification cadence (same as PATCH)
      responses:
        '200':
          description: The saved preference
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/news-feed/policies":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Policies a Must-Read can ask readers to accept (composer picker)
      description: |
        The options behind the composer's "Linked policy" dropdown. Send the
        chosen `id` back as `feed[policy_id]` on `POST /feeds`; the feed
        payload then returns it — with the reader's acceptance state — in the
        `policy` block.

        Only PUBLISHED policies are listed (a draft cannot be accepted), and
        the list is empty unless the tenant is entitled to Policy Hub, which is
        where reading and accepting happen. Same list, same rules as the web
        composer's picker (both go through `Comms::LinkablePolicies`).

        A lean row per option — one policy's full detail (type, version,
        acceptance state, e-signature requirement) is
        `GET /news-feed/policies/{id}`.

        Composer-gated: a caller who can neither compose nor post a must-read
        gets 403.
      parameters:
      - name: search
        in: query
        required: false
        schema:
          type: string
        description: Narrows by title (the picker's type-ahead).
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          maximum: 100
      responses:
        '200':
          description: Linkable policies
          content:
            application/json:
              schema:
                type: object
                required:
                - policies
                - meta
                properties:
                  policies:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                        requires_acknowledgment:
                          type: boolean
                          description: |
                            Whether accepting is even asked for. A policy that
                            requires no acknowledgment is still linkable (it
                            names what the must-read is about), but the card
                            should not promise an accept action for it.
                        url:
                          type: string
                          nullable: true
                          description: |
                            Absolute URL of the mobile Policy Hub screen — the
                            one surface that both reads and acknowledges. Null
                            when Policy Hub is not reachable by THIS caller, so
                            a client never renders a link that only bounces.
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
              example:
                policies:
                - id: 12
                  title: Heat Safety Procedure v4
                  requires_acknowledgment: true
                  url: https://acme.workforce.mangoapps.com/m/apps/policy-hub/policies/12
                meta:
                  total_count: 1
                  total_pages: 1
                  current_page: 1
                  per_page: 25
        '401':
          description: Unauthorized
        '403':
          description: Composing isn't enabled for this account
  "/news-feed/policies/{id}":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Details of one policy a Must-Read can ask readers to accept
      description: |
        Resolves a policy id the client already holds — from the `policy` block
        on a feed payload, from a saved draft, or from
        `GET /policy_hub/policies` — into the fields a composer's "Linked
        policy" field and a Must-Read card need to render. Send the `id` back as
        `feed[policy_id]` on `POST /feeds`; the feed payload then returns it,
        with the reader's acceptance state, in its `policy` block.

        Not a catalogue: enumerating and searching policies is
        `GET /policy_hub/policies`. Not the accept action either — the URL that
        accepts a policy in place is `acknowledge_url` on the `policy` block of
        a feed payload, which is where a Must-Read card gets it.

        404 for an id outside this business, for a DRAFT (never linkable — a
        draft cannot be accepted), and for every id when the tenant is not
        entitled to Policy Hub, which is where reading and accepting happen
        (the same entitlement rule the web composer's policy field applies —
        both go through `Comms::LinkablePolicies`). An ARCHIVED or RETIRED
        policy IS returned: a Must-Read published while it was live still names
        it, and its title is needed to render that card — `status` says it is no
        longer current and `url` goes null.

        Composer-gated: a caller who can neither compose nor post a must-read
        gets 403.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Policy detail
          content:
            application/json:
              schema:
                type: object
                required:
                - policy
                properties:
                  policy:
                    type: object
                    properties:
                      id:
                        type: integer
                      title:
                        type: string
                      description:
                        type: string
                        nullable: true
                      policy_type:
                        type: string
                      category:
                        type: string
                        nullable: true
                      status:
                        type: string
                        enum:
                        - published
                        - archived
                        - retired
                        description: |
                          `draft` is never returned (it 404s). A non-`published`
                          value means the policy is no longer current: keep
                          rendering the title on an existing Must-Read, but do
                          not offer it for a new one.
                      version_number:
                        type: integer
                      requires_acknowledgment:
                        type: boolean
                        description: |
                          Whether accepting is even asked for. A policy that
                          requires no acknowledgment is still linkable (it names
                          what the must-read is about), but the card should not
                          promise an accept action for it.
                      requires_esignature:
                        type: boolean
                        description: |
                          E-signature policies cannot be click-accepted from a
                          native client — the signing flow lives on the web app,
                          and `POST /policy_hub/policies/{id}/acknowledge`
                          refuses them.
                      published_at:
                        type: string
                        format: date-time
                        nullable: true
                      updated_at:
                        type: string
                        format: date-time
                      accepted:
                        type: boolean
                        description: |
                          Whether THIS caller has accepted the CURRENT version.
                          False for a caller whose acceptance HR has flagged for
                          re-acknowledgment, even though the underlying record
                          stays acknowledged — those are exactly the people
                          Policy Hub is chasing to re-accept.
                      accepted_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Null unless `accepted` is true.
                      url:
                        type: string
                        nullable: true
                        description: |
                          Absolute URL of the mobile Policy Hub screen — the one
                          surface that both reads and acknowledges. Null unless
                          the policy is still published AND Policy Hub is
                          reachable by THIS caller, so a client never renders a
                          link that only bounces.
              example:
                policy:
                  id: 12
                  title: Heat Safety Procedure v4
                  description: What to do above 95°F.
                  policy_type: safety
                  category: Safety
                  status: published
                  version_number: 4
                  requires_acknowledgment: true
                  requires_esignature: false
                  published_at: '2026-08-01T14:02:00Z'
                  updated_at: '2026-08-06T09:15:00Z'
                  accepted: false
                  accepted_at:
                  url: https://acme.workforce.mangoapps.com/m/apps/policy-hub/policies/12
        '401':
          description: Unauthorized
        '403':
          description: Composing isn't enabled for this account
        '404':
          description: No such policy in this business (or a draft
          or Policy Hub not entitled):
  "/feeds/{id}/feedback":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Send the post's author private feedback
      description: |
        A private note to the post's AUTHOR and no one else, always sent under
        the caller's name (owner decision 2026-10-16). Only the sender and the
        author ever see it. Asks `write:news_feed`; the caller must be in the
        post's audience and the post must have gone out (the same door as
        `POST /feeds/{id}/reactions`). Refused with 422 and one of these codes:
        `not_published`, `own_post` (your own post), `no_author`,
        `author_left` (the author is no longer a member), `already_open` (your
        earlier feedback on this post is still open), `invalid` (blank or over
        2,000 characters).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 2000
      responses:
        '201':
          description: Sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    "$ref": "#/components/schemas/NewsFeedPostFeedback"
                  message:
                    type: string
                    example: Feedback sent. Only Grace Lee can see it.
        '401':
          description: Unauthorized
        '403':
          description: "`not_authorized` — the caller is not in the post's audience;
            or Communications is unavailable to them."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found." — no such post, or a draft the
            caller may not read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: Refused — see the codes above.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feedback":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: The caller's private feedback on posts (Received or Sent)
      description: |
        `box=received` — feedback readers sent the caller about their posts
        (default); `box=sent` — feedback the caller sent other authors. Only
        the caller's own rows, newest first. Asks `read:news_feed`; the `can_*`
        flags are false for a token without `write:news_feed`.
      parameters:
      - name: box
        in: query
        required: false
        schema:
          type: string
          enum:
          - received
          - sent
          default: received
      - name: status
        in: query
        required: false
        schema:
          type: string
          enum:
          - open
          - resolved
          - all
          default: open
      - name: page
        in: query
        required: false
        schema:
          type: integer
          default: 1
      - name: per_page
        in: query
        required: false
        schema:
          type: integer
          default: 25
          maximum: 50
      responses:
        '200':
          description: One page of feedback
          content:
            application/json:
              schema:
                type: object
                required:
                - feedback
                - meta
                properties:
                  feedback:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedPostFeedback"
                  box:
                    type: string
                    enum:
                    - received
                    - sent
                  status:
                    type: string
                    enum:
                    - open
                    - resolved
                    - all
                  counts:
                    type: object
                    description: Rows in this box by status, before paging.
                    properties:
                      open:
                        type: integer
                      resolved:
                        type: integer
                  meta:
                    type: object
                    properties:
                      total_count:
                        type: integer
                      total_pages:
                        type: integer
                      current_page:
                        type: integer
                      per_page:
                        type: integer
                      has_next_page:
                        type: boolean
                      has_prev_page:
                        type: boolean
                  total_count:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
  "/news-feed/feedback/{id}":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: One piece of private feedback
      description: |
        Only its sender and the post's author may open it; to anyone else it
        is a 404. Opening it as the author marks it read. Asks `read:news_feed`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The feedback
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    "$ref": "#/components/schemas/NewsFeedPostFeedback"
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: '`not_found` "Feedback not found."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feedback/{id}/reply":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Reply to feedback (the author, once) — resolves it
      description: |
        The author's one private reply; the sender is notified and the
        feedback becomes resolved. Asks `write:news_feed`. 403 `not_recipient`
        for the sender; 422 `already_replied`, or `invalid` for a blank reply.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reply_body
              properties:
                reply_body:
                  type: string
                  maxLength: 2000
      responses:
        '200':
          description: Replied
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    "$ref": "#/components/schemas/NewsFeedPostFeedback"
        '401':
          description: Unauthorized
        '403':
          description: "`not_recipient` — only the post's author can reply; or Communications
            is unavailable."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feedback not found."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`already_replied` or `invalid`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feedback/{id}/resolve":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark feedback resolved (the author)
      description: Asks `write:news_feed`. 403 `not_recipient` for the sender; 422
        `already_resolved`.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    "$ref": "#/components/schemas/NewsFeedPostFeedback"
        '401':
          description: Unauthorized
        '403':
          description: "`not_recipient`, or Communications is unavailable."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feedback not found."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`already_resolved`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feedback/{id}/reopen":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Reopen resolved feedback (the author)
      description: |
        Asks `write:news_feed`. 403 `not_recipient` for the sender; 422
        `not_resolved`, or `newer_open` when the same person has newer open
        feedback on the same post (reply to that one).
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Reopened
          content:
            application/json:
              schema:
                type: object
                properties:
                  feedback:
                    "$ref": "#/components/schemas/NewsFeedPostFeedback"
        '401':
          description: Unauthorized
        '403':
          description: "`not_recipient`, or Communications is unavailable."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feedback not found."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`not_resolved` or `newer_open`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feedback/{id}/withdraw":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Withdraw your own feedback (the sender)
      description: |
        Asks `write:news_feed`. Deletes feedback the caller sent while it is
        still open and unanswered; the author's Inbox ask about it closes.
        403 `not_sender` for the author; 422 `not_withdrawable` once it is
        answered or resolved.
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  withdrawn:
                    type: boolean
                  id:
                    type: integer
        '401':
          description: Unauthorized
        '403':
          description: "`not_sender`, or Communications is unavailable."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feedback not found."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`not_withdrawable`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/media":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Upload a News Feed attachment ahead of the post (orphan media)
      description: |
        Mobile/API composer flow. **Send the file itself** as a
        `multipart/form-data` `file` part (with `media_type` and, optionally,
        `alt_text` / `width_px` / `height_px`): the server reads the real size
        and content type off the bytes, stores the file, and returns a
        FeedMedia row that is already finalized, in "orphan" state (`feed_id`
        is null) — there is no second call. Files over 200 MB go up in parts
        through Upload Sessions (`POST /uploads/sessions`, purpose
        `news_feed`). Finally the post is created with `POST /feeds` and
        `feed_media_ids: [<id>, ...]` — which atomically claims the orphans and
        attaches them to the new feed.

        DEPRECATED leg, kept for clients already in the field and available
        only on deployments that store files in S3: a JSON body with the
        declared metadata returns a presigned S3 PUT URL; the client PUTs the
        bytes and finalizes via `POST /media/{id}/complete`. On any other
        storage provider it answers 422 `direct_upload_unsupported`.

        Validation mirrors the nested per-feed endpoint: 10 in-flight
        attachments per user, per-type size caps (100MB image/gif,
        500MB video, 50MB file), MIME and extension blocklists, GIF /
        media admin flags.

        Orphan rows that are never claimed are swept after 24h by
        `NewsFeed::PurgeOrphanFeedMediaJob`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              - media_type
              properties:
                file:
                  type: string
                  format: binary
                  description: The attachment. Size and content type are read from
                    the bytes, never from the client.
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                alt_text:
                  type: string
                width_px:
                  type: integer
                  description: Image/gif natural width — skips server-side Vips decode
                    if supplied.
                height_px:
                  type: integer
          application/json:
            schema:
              type: object
              description: Deprecated presigned-PUT leg (S3 deployments only).
              required:
              - media_type
              - mime_type
              - original_filename
              - file_size_bytes
              properties:
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                mime_type:
                  type: string
                original_filename:
                  type: string
                file_size_bytes:
                  type: integer
                  minimum: 1
                alt_text:
                  type: string
                width_px:
                  type: integer
                  description: Image/gif natural width — skips server-side Vips decode
                    if supplied.
                height_px:
                  type: integer
      responses:
        '201':
          description: Orphan media row created. Multipart leg — the row is finalized
            and there is no `upload_url`. Deprecated JSON leg — PUT the bytes to `upload_url`,
            then POST /media/{id}/complete.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    type: object
                    description: Serialized FeedMedia.
                  upload_url:
                    type: string
                    description: Presigned S3 PUT URL.
                  storage_key:
                    type: string
                  expires_in:
                    type: integer
                    description: Presigned URL TTL in seconds.
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '422':
          description: Validation failed (mime/size/cap/admin-flag).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/media/{id}/complete":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Finalize an orphan media upload after the S3 PUT
      description: |
        Marks the orphan FeedMedia row as ready. For images/gifs, kicks
        off dimension extraction if `width_px`/`height_px` weren't sent on
        create. For videos, enqueues the transcode job — which produces a
        poster frame and, when the upload needs one, a 720p H.264 playback
        rendition (`playback_url`) — and (optionally) the auto-subtitle
        pipeline. For files, flips status to `ready` immediately.

        Only the uploader can finalize their own orphan rows.
      parameters:
      - in: path
        name: id
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Updated media row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    type: object
        '401':
          description: Unauthorized
        '403':
          "$ref": "#/components/responses/CommunicationsUnavailable"
        '404':
          description: Media not found (already claimed, deleted, or owned by another
            user).
  "/media/gif":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Attach a GIF picked from KLIPY (orphan media)
      description: |
        The GIF picker's "pick": send the KLIPY `slug` a search returned
        (`GET /news-feed/gifs`). The server looks the GIF up on KLIPY,
        downloads it and stores it exactly as an uploaded GIF — the same
        orphan FeedMedia row `POST /media` creates, finalized, with the GIF's
        KLIPY title as its alt text — so it is claimed by `POST /feeds`
        (`feed_media_ids`) or a comment (`comment_media_ids`) like any other
        attachment, and never depends on KLIPY's CDN afterwards. The same
        rules as an upload apply first: the Media uploads and GIF uploads
        switches, the 10-pending cap, the 100 MB GIF cap.

        `POST /feeds/{feed_id}/media/gif` is the same for a draft post (or,
        with `parent_type: comment` + `parent_id`, a comment), behind that
        door's own edit rules.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - slug
              properties:
                slug:
                  type: string
                  description: The KLIPY slug from GET /news-feed/gifs.
                q:
                  type: string
                  description: The search that found it (optional; reported to KLIPY
                    with the share).
      responses:
        '201':
          description: Orphan media row created and finalized.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    type: object
                    description: Serialized FeedMedia (media_type gif).
        '401':
          description: Unauthorized
        '403':
          description: Composing isn't enabled for this account
        '422':
          description: |
            Refused. Upload codes as for `POST /media` (`media_uploads_disabled`,
            `gif_uploads_disabled`, `attachment_cap_reached`, `file_too_large`),
            or `gif_not_found` / `gif_invalid_slug` (no such GIF any more),
            `gif_too_large`, `gif_not_configured` (no KLIPY key on this
            deployment), `gif_unavailable` (KLIPY did not answer).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/feeds/{feed_id}/media/gif":
    parameters:
    - in: path
      name: feed_id
      required: true
      schema:
        type: integer
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Attach a GIF picked from KLIPY to a post or one of its comments
      description: |
        `POST /media/gif` for a post the caller may edit: the GIF is fetched by
        the server and stored as the post's own media, exactly like an uploaded
        GIF, behind this post's edit rules (the post's author or a
        Communications admin before it goes out; after, while content editing
        is on and nobody has acknowledged it — and the post is marked edited).
        With `parent_type: comment` + `parent_id` it attaches to that comment
        instead, by its author within the comment's edit window.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - slug
              properties:
                slug:
                  type: string
                  description: The KLIPY slug from GET /news-feed/gifs.
                q:
                  type: string
                  description: The search that found it (optional).
                parent_type:
                  type: string
                  enum:
                  - comment
                parent_id:
                  type: integer
                  description: The comment, when parent_type is comment.
      responses:
        '201':
          description: Media row created and finalized.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    "$ref": "#/components/schemas/NewsFeedFeedMedia"
        '401':
          description: Unauthorized
        '403':
          description: The post can't be edited by the caller.
        '404':
          description: Post not found, or not visible to the caller.
        '422':
          description: |
            Refused — the codes `POST /media/gif` answers, plus the comment
            codes (`comment_not_found`, `not_comment_author`,
            `edit_window_expired`, `comment_attachment_cap_reached`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
  "/news-feed/gifs":
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Search GIFs (KLIPY), or list trending ones
      description: |
        The GIF picker's search, proxied to KLIPY so no provider key reaches
        the client. KLIPY's strictest content filter is always applied, and the
        caller is identified to KLIPY only by an opaque per-person hash. A blank
        `q` returns what is trending. Requires the tenant's Media uploads and
        GIF uploads switches (Settings › Media & Voice). Pick one with
        `POST /media/gif`.
      parameters:
      - in: query
        name: q
        schema:
          type: string
          maxLength: 100
        description: Words to search for; blank for trending.
      - in: query
        name: page
        schema:
          type: integer
          minimum: 1
          maximum: 50
          default: 1
      responses:
        '200':
          description: One page of GIFs (24 per page).
          content:
            application/json:
              schema:
                type: object
                properties:
                  gifs:
                    type: array
                    items:
                      type: object
                      properties:
                        slug:
                          type: string
                          description: What a pick sends back — KLIPY's per-response
                            token included.
                        key:
                          type: string
                          description: The GIF's durable name, the same on every page
                            (the slug without its token) — tell a GIF already shown
                            by this.
                        title:
                          type: string
                        preview_url:
                          type: string
                          description: A small preview on KLIPY's CDN, for the picker
                            grid only.
                        width:
                          type: integer
                        height:
                          type: integer
                  page:
                    type: integer
                  has_next:
                    type: boolean
                  provider:
                    type: string
                    enum:
                    - klipy
        '401':
          description: Unauthorized
        '403':
          description: "`media_uploads_disabled` / `gif_uploads_disabled` — the tenant
            turned these off, or Communications is not available to the caller."
        '502':
          description: "`gif_unavailable` — KLIPY did not answer."
        '503':
          description: "`gif_not_configured` — this deployment has no KLIPY key."
  "/feeds/{feed_id}/media":
    parameters:
    - in: path
      name: feed_id
      required: true
      description: The post (feed) id.
      schema:
        type: integer
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: List a post's attachments
      description: |
        The post's live attachments (images, GIFs, videos, files and link
        previews), in gallery order (`position`). Files attached to the
        post's comments are listed too — they carry a `comment_id`; the
        post's own files have `comment_id: null`. The cover image is listed
        with `cover: true`.

        Reading the attachments is reading the post: the caller must be in
        its audience — or, before it goes out, one of the people preparing
        it — or hold the "view any post" right. A draft or scheduled post the
        caller is not preparing answers `404` like a missing one.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      responses:
        '200':
          description: The post's attachments.
          content:
            application/json:
              schema:
                type: object
                required:
                - media
                properties:
                  media:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedFeedMedia"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/NewsFeedMediaReadForbidden"
        '404':
          "$ref": "#/components/responses/NewsFeedFeedNotFound"
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Attach a file to a post (or to one of its comments)
      description: |
        Adds an attachment to a post the caller may change. **Send the file
        itself** as a `multipart/form-data` `file` part with `media_type`
        (and, optionally, `alt_text`, `width_px`, `height_px`): the server
        reads the real size and type off the bytes, stores the file and
        answers with the finished row — there is no second call. Files over
        200 MB go up in parts through Upload Sessions
        (`POST /uploads/sessions`, purpose `news_feed`).

        **Who may add to the post.** The same rule body edits use: before the
        post goes out, its author (or the person writing it for them) or a
        Communications app admin whose reach covers it; after it went out,
        while the business's content-editing switch is on and until anyone
        has acknowledged it. Anyone else gets `403 feed_not_editable`. A post
        a delegate is writing for someone else is changed only by that person
        or the delegate. Adding to a published post marks it **edited**.

        **A comment's attachment.** With `parent_type: comment` and
        `parent_id`, the file attaches to that comment of this post instead;
        the post's edit rule is skipped and the comment's own rules apply (its
        author, inside the comment edit window) — refusals come back as `422`
        codes. Delete a comment's file with
        `DELETE /comments/{comment_id}/media/{id}`.

        DEPRECATED leg, kept for clients already in the field and available
        only on deployments that store files in S3: a JSON body with the
        declared metadata reserves a row and returns a presigned PUT
        `upload_url`; PUT the bytes there, then call
        `PATCH /feeds/{feed_id}/media/{media_id}/complete`. On any other
        storage it answers `422 direct_upload_unsupported`.

        Limits: 10 attachments per post (the cover image and link previews
        do not count) and a per-comment cap for a comment's files; 100 MB per
        image or GIF, 500 MB per video, 50 MB per file; blocked types and
        extensions; the admin's Media uploads and GIF uploads switches.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              - media_type
              properties:
                file:
                  type: string
                  format: binary
                  description: The attachment. Size and content type are read from
                    the bytes, never from the client.
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                alt_text:
                  type: string
                  description: Required for images when the business requires alt
                    text (`alt_text_required`).
                width_px:
                  type: integer
                  description: Image/GIF natural width — skips the server-side decode
                    when given.
                height_px:
                  type: integer
                parent_type:
                  type: string
                  enum:
                  - comment
                  description: Attach to a comment of this post instead of the post.
                parent_id:
                  type: integer
                  description: The comment, when parent_type is comment.
          application/json:
            schema:
              type: object
              description: Deprecated presigned-PUT leg (S3 deployments only).
              required:
              - media_type
              - mime_type
              - original_filename
              - file_size_bytes
              properties:
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                mime_type:
                  type: string
                original_filename:
                  type: string
                file_size_bytes:
                  type: integer
                  minimum: 1
                alt_text:
                  type: string
                width_px:
                  type: integer
                height_px:
                  type: integer
                parent_type:
                  type: string
                  enum:
                  - comment
                parent_id:
                  type: integer
      responses:
        '201':
          description: Attachment created. Multipart leg — the row is finished and
            only `media` is returned. Deprecated JSON leg — also `upload_url`, `storage_key`
            and `expires_in`; PUT the bytes to `upload_url`, then call `complete`.
          content:
            application/json:
              schema:
                type: object
                required:
                - media
                properties:
                  media:
                    "$ref": "#/components/schemas/NewsFeedFeedMedia"
                  upload_url:
                    type: string
                    description: Deprecated leg only. Presigned PUT URL.
                  storage_key:
                    type: string
                    description: Deprecated leg only.
                  expires_in:
                    type: integer
                    description: Deprecated leg only. Seconds the upload_url stays
                      valid.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/NewsFeedMediaWriteForbidden"
        '404':
          "$ref": "#/components/responses/NewsFeedFeedNotFound"
        '422':
          "$ref": "#/components/responses/NewsFeedMediaRefused"
  "/feeds/{feed_id}/media/{media_id}":
    parameters:
    - in: path
      name: feed_id
      required: true
      description: The post (feed) id.
      schema:
        type: integer
    - in: path
      name: media_id
      required: true
      description: The attachment id.
      schema:
        type: integer
    get:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Get one attachment of a post
      description: |
        One live attachment of the post — a post's own file or one of its
        comments' files. Same read rule as the list.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      responses:
        '200':
          description: The attachment.
          content:
            application/json:
              schema:
                type: object
                required:
                - media
                properties:
                  media:
                    "$ref": "#/components/schemas/NewsFeedFeedMedia"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/NewsFeedMediaReadForbidden"
        '404':
          "$ref": "#/components/responses/NewsFeedMediaNotFound"
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Remove an attachment from a post
      description: |
        Removes one of the post's own attachments, under the same rule as
        adding one (see `POST /feeds/{feed_id}/media`): anyone else gets
        `403 feed_not_editable`. A link preview stays removable by the post's
        author at any time. Removing from a published post marks it
        **edited**. The row is soft-deleted.

        A comment's attachment is not removed here — that answers
        `422 use_comment_media_endpoint`; use
        `DELETE /comments/{comment_id}/media/{id}`.

        **Required Scopes:** `destructive:news_feed` — removing a file is a
        delete-level act. A token without it gets
        `403 insufficient_permissions` ("Required scope:
        destructive:news_feed") before anything else is checked. The sign-in
        tokens hold it; a token made by hand holds it only when granted. A
        browser session is asked no scope.
      responses:
        '204':
          description: Removed.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:news_feed`.
            - `feed_not_editable` — the caller may not change this post's attachments. The message says why: "You can't change the attachments on someone else's post.", "Attachments on this post can no longer be changed." (editing is off, or someone acknowledged it), or "Only <name> and the person writing it for them can change this post's attachments before it goes out."
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NewsFeedMediaNotFound"
        '422':
          description: "`use_comment_media_endpoint` — the file belongs to a comment;
            the message names the comment-scoped URL to call."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{feed_id}/media/{media_id}/complete":
    parameters:
    - in: path
      name: feed_id
      required: true
      description: The post (feed) id.
      schema:
        type: integer
    - in: path
      name: media_id
      required: true
      description: The attachment id.
      schema:
        type: integer
    patch:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Finish a presigned upload (deprecated leg)
      description: |
        The deprecated JSON leg's second call, after the client PUT the bytes
        to the presigned `upload_url`: images and GIFs get their dimensions
        read (unless `width_px`/`height_px` were sent), videos are queued for
        transcoding (a poster frame and, when needed, a 720p playback
        rendition) and files are marked ready. Finishing a post's file marks a
        published post **edited**. The multipart leg never needs this call.

        Only someone who could have made the upload may finish it: the person
        who uploaded it, the comment's author for a comment's file, or someone
        who may change the post's attachments now.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      responses:
        '200':
          description: The finished attachment.
          content:
            application/json:
              schema:
                type: object
                required:
                - media
                properties:
                  media:
                    "$ref": "#/components/schemas/NewsFeedFeedMedia"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed`.
            - `feed_not_editable` — a post's file the caller did not upload and may not change now (the message says why, as for DELETE).
            - `comment_not_editable` — "Only the comment's author can change its attachments."
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NewsFeedMediaNotFound"
  "/news-feed/feeds/{id}/read_event":
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Record that the caller read a post
      description: |
        Logs the caller's view of a published post for its engagement
        analytics. The first call records `read_at`; every call moves
        `last_seen_at`. A call reporting `scroll_depth_pct` of **80 or more**
        records a *qualified read* once (`qualified_read_at`), which also adds
        a late reader to the post's audience snapshot and refreshes the post's
        analytics. Idempotent — call it on every open.

        An unacknowledged **must-read** logs no view: the call answers
        `200` with `logged: false` and `acknowledgement_required: true`, and
        nothing is written until the caller acknowledges the post
        (`POST /feeds/{id}/acknowledge`).

        The caller must be able to view the post — its audience, or an
        admin. A draft or scheduled post answers `404` to everyone except the
        people preparing it, who get `422 not_published`.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      parameters:
      - in: path
        name: id
        required: true
        description: The post (feed) id.
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scroll_depth_pct:
                  type: number
                  minimum: 0
                  maximum: 100
                  description: How far the caller scrolled, in percent. Clamped to
                    0..100. Omitted, zero, negative or not a number = a plain view
                    (no qualified read).
                  example: 85
      responses:
        '200':
          description: The caller's read record after this call. When `logged` is
            false (an unacknowledged must-read) every timestamp and the depth are
            null and `acknowledgement_required` is true.
          content:
            application/json:
              schema:
                type: object
                required:
                - logged
                - read_at
                - last_seen_at
                - qualified_read_at
                - scroll_depth_pct
                properties:
                  logged:
                    type: boolean
                  acknowledgement_required:
                    type: boolean
                    description: Present (true) only when `logged` is false.
                  read_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: First time the caller opened the post.
                  last_seen_at:
                    type: string
                    format: date-time
                    nullable: true
                  qualified_read_at:
                    type: string
                    format: date-time
                    nullable: true
                    description: When the caller first scrolled 80% or more; null
                      until then.
                  scroll_depth_pct:
                    type: number
                    nullable: true
                    description: The depth recorded with the qualified read.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `forbidden` "Cannot view this feed." — the caller is not in the post's audience.
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found." — no such post in the business,
            or a draft or scheduled post the caller is not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`not_published` — the post has not gone out yet (only its
            preparers get this)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/signals/{id}/complete":
    parameters:
    - in: path
      name: id
      required: true
      description: The action-item signal id — a signal the post or one of its comments
        carries whose `signal_types` include `action_item`.
      schema:
        type: integer
    post:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Mark an action item done
      description: |
        Marks an action item raised on a post (or one of its comments) done by
        the caller. Who may: business admins, Communications app admins, the
        person who wrote the post or comment that carries it, and the post's
        author — anyone else gets `403`. The caller must also be able to view
        the post. Idempotent: a second call (or a double tap) answers the
        first completion's time.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      responses:
        '200':
          description: Done.
          content:
            application/json:
              schema:
                type: object
                required:
                - signal_id
                - completed_at
                properties:
                  signal_id:
                    type: integer
                  completed_at:
                    type: string
                    format: date-time
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `forbidden` "Feed not visible." — the caller may not view the post.
            - `forbidden` "Not authorized to complete this action." — the caller is not an admin, a Communications app admin, or the author of the post or of the content that raised it.
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Signal not found." — no such signal in the business,
            or one on a draft or scheduled post the caller is not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `not_published` — the post has not gone out yet (only its preparers get this).
            - `validation_failed` "Not an action item." — the signal is not an action item.
            - `validation_failed` "Could not mark this action done. Please try again." — the write failed; retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - News Feed
      security:
      - BearerAuth: []
      summary: Undo the caller's own "done" on an action item
      description: |
        Removes the caller's own completion of the action item. Answers
        `204` whether or not the caller had marked it done; nobody else's
        completion is touched.

        **Required Scopes:** `write:news_feed` — undoing your own toggle is
        not a delete-level act. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      responses:
        '204':
          description: The caller's completion is gone (or there was none).
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Signal not found." — no such signal in the business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comments/{id}/reactions":
    parameters:
    - name: id
      in: path
      required: true
      description: Comment id.
      schema:
        type: integer
    get:
      tags:
      - News Feed
      summary: List the people who reacted to a comment
      description: |
        The reactions on one comment in two shapes: `reactors`, a flat list
        ordered by most recent reaction first (a swap counts as a new
        reaction), and `grouped`, the same reactors bucketed by `emoji_key`
        and sorted by count, largest first. At most the 200 most recent
        reactions are returned, and `total` counts the reactors returned
        (so it never exceeds 200) — the comment's full count is
        `aggregate.total` on the POST answer and `reactions.total` on the
        comment itself.

        The comment's post decides who may ask: a comment on a post outside
        the caller's audience is `403 not_authorized`; a comment on a draft
        or scheduled post is `404 not_found` to everyone but the people
        preparing it, who get `422 not_published`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The comment's reactors, flat and grouped by emoji
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedReactorList"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business." (the admin turned comments, reactions and replies off).
            - `not_authorized` — "You do not have access to this comment": the caller is not in the post's audience.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Comment not found" — no such comment in the business,
            a deleted comment, or one on a draft or scheduled post the caller is not
            preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`not_published` "This post hasn''t been published yet." —
            the comment is on a draft or scheduled post the caller is preparing; nobody
            engages with it before it goes out.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - News Feed
      summary: React to a comment (set, swap or toggle off)
      description: |
        Sets the caller's one reaction on the comment. With no reaction yet
        it is created; with a different emoji it is swapped in place; with
        the SAME emoji it is removed (a toggle — `reaction` is then null).
        Every branch answers 200 with the comment's refreshed `aggregate`,
        pushes the new counts to everyone watching the post, and marks the
        comment's post read for the caller.

        `emoji_key` must be one of the 14 catalog keys and — unless it is the
        reaction the caller already holds, which can always be toggled off —
        one the business's reaction palette offers (an admin setting; by
        default the eight expressive keys). Anything else, including a
        missing `emoji_key`, is `422 invalid_emoji`.

        The comment's post decides who may react, as on the GET.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. The sign-in tokens and a token made
        by hand hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji_key
              properties:
                emoji_key:
                  "$ref": "#/components/schemas/NewsFeedEmojiKey"
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
              - emoji_key
              properties:
                emoji_key:
                  "$ref": "#/components/schemas/NewsFeedEmojiKey"
      responses:
        '200':
          description: The caller's reaction after the tap, and the comment's aggregate
          content:
            application/json:
              schema:
                type: object
                required:
                - reaction
                - aggregate
                properties:
                  reaction:
                    allOf:
                    - "$ref": "#/components/schemas/NewsFeedReactionRecord"
                    nullable: true
                    description: The caller's reaction now; null when this tap removed
                      it.
                  aggregate:
                    "$ref": "#/components/schemas/NewsFeedReactionsAggregate"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business."
            - `not_authorized` — "You do not have access to this comment": the caller is not in the post's audience.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Comment not found" — no such comment in the business,
            a deleted comment, or one on a draft or scheduled post the caller is not
            preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `invalid_emoji` — "That reaction isn't available." (not one of the 14 catalog keys, missing, or not in the business's reaction palette).
            - `not_published` — "This post hasn't been published yet.": the comment is on a draft or scheduled post the caller is preparing.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
      - News Feed
      summary: Remove the caller's reaction from a comment
      description: |
        Removes the caller's reaction from the comment, whatever its emoji.
        Answers `204` whether or not there was one. Unreacting is the
        caller's own toggle, not a delete-level act. A removal sends the new
        count to everyone viewing the comment, as adding one does.

        The comment's post decides who may ask, as on the GET.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. The sign-in tokens and a token made
        by hand hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Removed, or there was nothing to remove.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business."
            - `not_authorized` — "You do not have access to this comment": the caller is not in the post's audience.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Comment not found" — no such comment in the business,
            a deleted comment, or one on a draft or scheduled post the caller is not
            preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`not_published` "This post hasn''t been published yet." —
            the comment is on a draft or scheduled post the caller is preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/comments/{comment_id}/media/{id}":
    parameters:
    - name: comment_id
      in: path
      required: true
      description: Comment id.
      schema:
        type: integer
    - name: id
      in: path
      required: true
      description: Id of one of the comment's live attachments (a FeedMedia row).
      schema:
        type: integer
    delete:
      tags:
      - News Feed
      summary: Remove an attachment from a comment
      description: |
        Takes one file off a comment. This is the comment-scoped delete path;
        a comment's files are uploaded through `POST /feeds/{feed_id}/media`
        (`parent_type=comment`) or `POST /media` + `comment_media_ids`, and
        `DELETE /feeds/{feed_id}/media/{media_id}` refuses them.

        The removal is an edit of the comment, under the comment's edit
        rules: only its author, while the comment is live, within 15 minutes
        of when it was written, and while the business's "Allow content
        editing" setting is on. Anyone else — an admin included — gets
        `403 comment_not_editable`. Like any edit it stamps the comment
        edited (`edited_at`). The file is soft-deleted. While the business
        has comments switched off, no comment is edited: `403
        comments_disabled`.

        The last file of a comment with no words is refused
        (`422 comment_would_be_empty`) rather than leaving a blank comment in
        the thread.

        The comment's post decides who may ask: a comment on a post outside
        the caller's audience is `403 not_authorized`; on a draft or
        scheduled post it is `404 not_found` to everyone but the people
        preparing it, who get `422 not_published`.

        **Required Scopes:** `destructive:news_feed` — removing a file is a
        delete-level act. A token without it gets
        `403 insufficient_permissions` ("Required scope:
        destructive:news_feed") before anything else is checked. The sign-in
        tokens hold it; a token made by hand holds it only when granted. A
        browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Removed.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:news_feed` ("Required scope: destructive:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business."
            - `not_authorized` — "You do not have access to this comment": the caller is not in the post's audience.
            - `comment_not_editable` — "Only the comment's author can change its attachments." (the caller did not write it) or "This comment's attachments can no longer be changed." (the author, after the 15-minute window, on a deleted comment, or with content editing turned off).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` "Comment not found" — no such comment in the business, or one on a draft or scheduled post the caller is not preparing.
            - `not_found` "Media not found" — no live attachment with this id on this comment.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `comment_would_be_empty` — "Add some text or keep at least one attachment.": this is the last file of a comment with no words.
            - `not_published` — "This post hasn't been published yet.": the comment is on a draft or scheduled post the caller is preparing.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/media/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Id of an orphan upload returned by `POST /media` or `POST /media/gif`.
      schema:
        type: integer
    delete:
      tags:
      - News Feed
      summary: Discard an orphan upload
      description: |
        Discards a file the caller uploaded ahead of a post or comment
        (`POST /media`, `POST /media/gif`) and has not attached yet — the
        composer's "remove" on a pending attachment. The row is soft-deleted
        and can no longer be claimed through `feed_media_ids` or
        `comment_media_ids`.

        Only the caller's own orphan rows in their business answer here. A
        row someone else uploaded, one already claimed onto a post or
        comment, or one already discarded is `404 not_found`, exactly like a
        missing id; a claimed file is removed through
        `DELETE /feeds/{feed_id}/media/{media_id}` or
        `DELETE /comments/{comment_id}/media/{id}`.

        **Required Scopes:** `destructive:news_feed` — removing a file is a
        delete-level act. A token without it gets
        `403 insufficient_permissions` ("Required scope:
        destructive:news_feed") before anything else is checked. The sign-in
        tokens hold it; a token made by hand holds it only when granted. A
        browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '204':
          description: Discarded.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:news_feed` ("Required scope: destructive:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Media not found" — no live, unattached upload
            with this id that the caller made in this business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{id}/mentionable_users":
    get:
      tags:
      - News Feed
      summary: People who can be @-mentioned on a post
      description: |
        The @-mention picker for a post's comment box and composer. Only
        people the post's comments may mention are offered: the post's
        audience, less anyone the post is held from until a required
        language is checked. At most 5 people are returned.

        - **`q` given** — active people in the business whose first, last,
          preferred or full name, or email, contains `q` (case-insensitive;
          the first 100 characters are used), names that START with it
          first, then by first and last name. The caller is never offered.
          Each row carries `meta`, a secondary line (job title and work
          location as far as the People directory shows them to the caller,
          then email), and `detail`: the person's job title, and — when two
          people on the page share a name — the location, department or
          Employee ID that tells them apart, each only as far as the People
          directory shows it to the caller.
        - **`q` blank or absent** — the first 5 people of that audience by
          first and last name, as `id` / `name` / `avatar_url`, plus the same
          `detail`. The caller is not left out of this list.

        Who may ask: anyone who may read the post — its audience once it has
        gone out, the people preparing it before. A draft or scheduled post
        is `404 not_found` to anyone else.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Post (feed) id.
        schema:
          type: integer
      - name: q
        in: query
        required: false
        description: What was typed after the "@". Blank lists the audience by name.
        schema:
          type: string
      responses:
        '200':
          description: Up to 5 people the caller may mention on this post
          content:
            application/json:
              schema:
                type: object
                required:
                - users
                properties:
                  users:
                    type: array
                    maxItems: 5
                    items:
                      "$ref": "#/components/schemas/NewsFeedMentionableUser"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `discussion_layer_disabled` — "The discussion layer is disabled for this business."
            - `not_authorized` — "You do not have access to this feed": the caller is not in the post's audience.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found" — no such post in the business,
            or a draft or scheduled post the caller is not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{id}/poll_summary":
    get:
      tags:
      - News Feed
      summary: Get the AI summary of a closed poll
      description: |
        The AI-written outcome of a poll post, for the summary card beside
        the results. Read-only — there is no regenerate endpoint.

        Always `200` for a poll the caller may read. When there is nothing
        to show, `summary` is null and `reason` says why, checked in this
        order:

        - `feature_disabled` — the business's AI poll summaries setting is off.
        - `not_closed` — the poll is still open.
        - `not_available` — no summary has been written yet, or the newest
          attempt failed.

        Otherwise `summary` is the newest summary (always `succeeded`) and
        there is no `reason`.

        Who may ask: anyone who may read the post — its audience once it has
        gone out, the people preparing it before — and, for a post that has
        gone out, holders of the "view any post" grant (by default the
        Communications app admins) from outside its audience. A post that is
        not a poll is `404 not_found`, as is a draft or scheduled poll to
        anyone not preparing it.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Poll post (feed) id.
        schema:
          type: integer
      responses:
        '200':
          description: The poll's summary, or null and the reason there is none
          content:
            application/json:
              schema:
                type: object
                required:
                - summary
                properties:
                  summary:
                    allOf:
                    - "$ref": "#/components/schemas/NewsFeedPollSummary"
                    nullable: true
                  reason:
                    type: string
                    enum:
                    - feature_disabled
                    - not_closed
                    - not_available
                    description: Present only when `summary` is null.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `not_authorized` — "You do not have access to this feed": the caller is not in the post's audience and holds no "view any post" grant.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found" — no such post in the business,
            a post that is not a poll, or a draft or scheduled poll the caller is
            not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{id}/summary":
    get:
      tags:
      - News Feed
      summary: Get the AI summary of a post's discussion
      description: |
        The newest AI summary of the post's comment thread (newest by
        `generated_at`). That row can be a failed attempt: `status` is then
        `failed`, `failure_reason` says why, and the `summary` fields are
        blank or defaults. A post with no summary yet is
        `404 not_found` "No summary available".

        Who may ask: anyone who may read the post — its audience once it has
        gone out (and, outside it, a holder of the "view any post" grant —
        Communications app admins by default), the people preparing it
        before. A draft or scheduled post is `404 not_found` to anyone else.
        The business's AI discussion summaries setting must be on
        (`403 forbidden` otherwise).

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. The sign-in tokens hold it; a
        browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Post (feed) id.
        schema:
          type: integer
      responses:
        '200':
          description: The newest discussion summary
          content:
            application/json:
              schema:
                type: object
                required:
                - summary
                properties:
                  summary:
                    "$ref": "#/components/schemas/NewsFeedDiscussionSummary"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `not_authorized` — "You do not have access to this feed": the caller is not in the post's audience and holds no "view any post" grant.
            - `forbidden` — "Discussion summarization is disabled for this business": the AI discussion summaries setting is off.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: |
            - `not_found` "Feed not found" — no such post in the business, or a draft or scheduled post the caller is not preparing.
            - `not_found` "No summary available" — the post has no summary yet.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/feeds/{id}/summary/regenerate":
    post:
      tags:
      - News Feed
      summary: Ask for a fresh AI summary of a post's discussion
      description: |
        Queues a new AI summary of the post's comment thread and answers
        `202` at once; the summary is written in the background and then
        read with `GET /feeds/{id}/summary`. No request body.

        Only the post's author, a business admin (admin or above) or a
        Communications app admin may ask (`403 not_authorized` otherwise),
        only on a post that has gone out (`422 not_published` for a draft or
        scheduled post, even to the people preparing it), and only when the
        post has no successful summary yet or has gathered at least 20 new
        comments since the last successful one (`422` otherwise). The
        business's AI discussion summaries setting must be on. Each accepted
        request is recorded in the post's audit log (best effort — a failed
        audit write does not fail the request).

        Each request counts against the caller's hourly allowance for this
        feature, shared with the web's Update Summary button (30 an hour).
        Past it the answer is `200` with `{ "ok": false, "code":
        "rate_limited", "error": "…", "retry_at": "…" }` and nothing is
        queued; `retry_at` says when the allowance lifts (the start of the
        next hour).

        The post must be one the caller may read — its audience once it has
        gone out (or, outside it, a holder of the "view any post" grant), the
        people preparing it before; a draft or scheduled post is
        `404 not_found` to anyone else.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. The sign-in tokens and a token made
        by hand hold it; a browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Post (feed) id.
        schema:
          type: integer
      responses:
        '202':
          description: The new summary is queued
          content:
            application/json:
              schema:
                type: object
                required:
                - status
                - previous_summary_id
                properties:
                  status:
                    type: string
                    enum:
                    - enqueued
                  previous_summary_id:
                    type: integer
                    nullable: true
                    description: The newest successful summary at the time of the
                      request; null when there was none.
        '200':
          description: The caller's hourly allowance for summaries is used up; nothing
            was queued.
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - code
                - error
                properties:
                  ok:
                    type: boolean
                    enum:
                    - false
                  code:
                    type: string
                    enum:
                    - rate_limited
                  error:
                    type: string
                    description: '"You''ve reached the hourly limit for this feature.
                      Try again after 3:00 PM."'
                  retry_at:
                    type: string
                    format: date-time
                    description: When the hourly limit lifts (the start of the next
                      UTC hour), the same moment the `error` sentence names.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `not_authorized` — "You do not have access to this feed" (the caller is not in the post's audience and holds no "view any post" grant) or "Author or admin required" (the caller is neither the post's author, a business admin nor a Communications app admin).
            - `forbidden` — "Discussion summarization is disabled for this business": the AI discussion summaries setting is off.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found" — no such post in the business,
            or a draft or scheduled post the caller is not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `not_published` "This post hasn't been published yet." — the post is a draft or scheduled; nothing is summarized before it goes out.
            - `validation_failed` "Update Summary is available after 20 new comments since the last summary." — the post has a successful summary and fewer than 20 live comments have been added since it was written.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feeds/{id}/analytics":
    get:
      tags:
      - News Feed
      summary: Get a post's engagement analytics
      description: |
        The engagement figures for one post — the twin of the web Engagement
        drawer: reach, reads, acknowledgements, reactions and action items,
        plus a day-by-day timeline of the cumulative read and acknowledgement
        rates.

        Who may: the post's author, a business admin, or a Communications app
        admin. Anyone else gets `403 forbidden`. A draft or scheduled post
        answers `404` to everyone except the people preparing it.

        The figures are recomputed on every call (never served from the
        15-minute cache). Every count is taken over the post's audience
        snapshot — the people it was sent to — so the author, and an admin
        reading a post aimed at others, are not counted. `metrics.reactions_count`
        counts reactions on the post itself, not on its comments.

        `timeline` has one entry per day in the business's time zone, from the
        day the post was published (created, for an unsent post) to 30 days
        later or today, whichever comes first; each entry's rates are
        cumulative to the end of that day.

        Works council: while the post's audience holds a group whose names are
        hidden and that group is under the company's smallest group, reach,
        reads and acknowledgements (tiles and timeline) count only the people
        who may be named, and `names_hidden` is added to the response.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The post (feed) id.
        schema:
          type: integer
      responses:
        '200':
          description: The post's engagement figures
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedPostAnalytics"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "Not authorized to view post analytics." — the caller is not the post's author, a business admin or a Communications app admin.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found." — no such post in the business,
            or a draft or scheduled post the caller is not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/feeds/{id}/analytics/export":
    get:
      tags:
      - News Feed
      summary: Export a post's acknowledgement roster as CSV
      description: |
        The post's audience as a CSV file, one row per person in its audience
        snapshot, ordered by last name then first name, with the time each
        person acknowledged it (blank when they have not). The path also
        routes with a `.csv` suffix; no other format is accepted.

        Columns: `feed_id`, `employee_id`, `employee_name`, `employee_email`,
        `acknowledged_at`. A person with no name shows their email, else
        "(former employee)". Name and email cells are guarded against
        spreadsheet formula injection.

        Works council: people whose names are hidden get no row of their own;
        they are counted in one closing row whose `employee_id` is blank and
        whose `employee_name` is the note that says so.

        **Large audiences:** when the audience snapshot holds more than 1,000
        people the file is not returned. The export is queued instead and the
        call answers `202` with a JSON body whose `delivery` says where the
        file will arrive: `email` — emailed to the caller when it is ready —
        or, for a caller no email can reach (no address on file), `in_app` —
        a notification linking to the finished file under Background tasks
        (`/background_tasks/{task_id}`), which carries every row.

        Who may: the post's author, a business admin, or a Communications app
        admin, as for `GET /news-feed/feeds/{id}/analytics`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The post (feed) id.
        schema:
          type: integer
      responses:
        '200':
          description: The CSV, streamed as an attachment named `engagement-<title>-<id>.csv`
            (the post's headline as a slug of at most 80 characters, or `post` when
            it has none).
          headers:
            Content-Disposition:
              description: '`attachment; filename="engagement-<title>-<id>.csv"`'
              schema:
                type: string
          content:
            text/csv:
              schema:
                type: string
                example: |
                  feed_id,employee_id,employee_name,employee_email,acknowledged_at
                  412,88,Ana Lopez,ana@example.com,2026-10-02 14:03:11 UTC
                  412,91,Ben Ortiz,,
        '202':
          description: The audience is over 1,000 people; the export was queued and
            will be emailed, or sent to the caller's notifications.
          content:
            application/json:
              schema:
                type: object
                required:
                - status
                - delivery
                - message
                properties:
                  status:
                    type: string
                    enum:
                    - accepted
                  delivery:
                    type: string
                    enum:
                    - email
                    - in_app
                    description: 'Where the file will arrive: `email`, or `in_app`
                      (a notification linking to it) for a caller no email can reach.'
                  message:
                    type: string
                    description: The same, as a sentence to show the caller.
                    example: Export queued (1450 rows). You'll get a notification
                      with the download link when it's ready.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "Not authorized to view post analytics." — the caller is not the post's author, a business admin or a Communications app admin.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found." — no such post in the business,
            or a draft or scheduled post the caller is not preparing.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/knowledge-base":
    get:
      tags:
      - News Feed
      summary: List knowledge-base entries (admin)
      description: |
        Every verified answer in the business's knowledge base — the admin
        table — newest first (`marked_at` descending), 50 per page. Each
        entry is a question post whose answer was marked correct.

        Optional filters: `status` (any other value is ignored, never a 400)
        and `q`, a full-text search of the question and answer that applies
        from 2 characters and then orders the page by relevance, newest
        first among equals.

        Who may: business admins and Communications app admins. Anyone else
        gets `403 forbidden`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        description: Only entries in this state. Any other value is ignored.
        schema:
          type: string
          enum:
          - active
          - outdated
      - name: q
        in: query
        description: Full-text search; ignored when shorter than 2 characters.
        schema:
          type: string
      - name: page
        in: query
        description: Page number (50 entries a page).
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: One page of entries, with the admin lifecycle fields
          content:
            application/json:
              schema:
                type: object
                required:
                - results
                - total
                - page
                - total_pages
                properties:
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedKnowledgeBaseAdminEntry"
                  total:
                    type: integer
                    description: Entries matching the filters, across all pages.
                  page:
                    type: integer
                  total_pages:
                    type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "Administrative access required." — the caller is not a business admin or a Communications app admin.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/knowledge-base/search":
    get:
      tags:
      - News Feed
      summary: Search the knowledge base
      description: |
        Searches the active verified answers the caller may see, ranked by
        relevance (newest first among equals), 20 per page. An answer
        inherits the audience of the question post it came from: an answer
        from a post aimed at everyone is open to all, one from a post aimed at
        groups only to their members, one from a post aimed at the author's
        direct reports only to people who report to that author, and an answer
        from a question held from the caller's place until its required
        language is checked is left out. Outdated answers are never returned.

        Any signed-in member may search; no admin role is needed.

        A query shorter than 2 characters (after trimming) returns no results
        and a smaller body — `{ "results": [], "total": 0, "page": 1 }`, with
        no `total_pages`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: The search text (full-text, English stemming).
        schema:
          type: string
        example: parking permit
      - name: page
        in: query
        description: Page number (20 results a page).
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: One page of matching answers
          content:
            application/json:
              schema:
                type: object
                required:
                - results
                - total
                - page
                properties:
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedKnowledgeBaseEntry"
                  total:
                    type: integer
                  page:
                    type: integer
                  total_pages:
                    type: integer
                    description: Absent when the query was too short to search.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/knowledge-base/export":
    get:
      tags:
      - News Feed
      summary: Export the knowledge base (admin)
      description: |
        Every knowledge-base entry in the business, active and outdated,
        newest first, as a CSV (the default) or JSON file. No filters apply.
        The format is the `format` query parameter or a `.csv` / `.json`
        suffix on the path; any other value falls back to CSV.

        CSV columns: `id`, `question_title`, `question_body`, `answer_text`,
        `answerer`, `segment`, `marked_at`, `status`, `outdated_reason`
        (text cells are guarded against spreadsheet formula injection). JSON
        is an array of `NewsFeedKnowledgeBaseExportRow`, which also carries
        `outdated_at`.

        **Large exports:** with more than 1,000 entries the file is not
        returned. The export is queued and the call answers `202`, whose
        `delivery` says where the file will arrive: `email` — emailed to the
        caller (cut to fit an email attachment, with the email saying how
        many entries it holds) — or, for a caller no email can reach (no
        address on file), `in_app` — a notification linking to the finished
        file under Background tasks (`/background_tasks/{task_id}`), which
        holds every entry.

        Who may: business admins and Communications app admins. Anyone else
        gets `403 forbidden`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: format
        in: query
        description: File format. Any other value means `csv`.
        schema:
          type: string
          enum:
          - csv
          - json
          default: csv
      responses:
        '200':
          description: The file, as an attachment named `knowledge_base_<business_id>_<YYYYMMDD>.<csv|json>`.
          headers:
            Content-Disposition:
              description: '`attachment; filename="knowledge_base_<business_id>_<YYYYMMDD>.<format>"`'
              schema:
                type: string
          content:
            text/csv:
              schema:
                type: string
                example: |
                  id,question_title,question_body,answer_text,answerer,segment,marked_at,status,outdated_reason
                  31,Where do I park?,Where do I park during the works?,Lot B until June.,Ana Lopez,All employees,2026-09-14T10:02:00Z,active,
            application/json:
              schema:
                type: array
                items:
                  "$ref": "#/components/schemas/NewsFeedKnowledgeBaseExportRow"
        '202':
          description: More than 1,000 entries; the export was queued and will be
            emailed, or sent to the caller's notifications.
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - async
                - delivery
                - message
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  async:
                    type: boolean
                    enum:
                    - true
                  delivery:
                    type: string
                    enum:
                    - email
                    - in_app
                    description: 'Where the file will arrive: `email`, or `in_app`
                      (a notification linking to it) for a caller no email can reach.'
                  message:
                    type: string
                    description: The same, as a sentence to show the caller.
                    example: Export is being generated. You'll receive an email when
                      it's ready.
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "Administrative access required." — the caller is not a business admin or a Communications app admin.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/knowledge-base/{id}/mark_outdated":
    patch:
      tags:
      - News Feed
      summary: Mark a verified answer outdated (admin)
      description: |
        Retires a verified answer: its status becomes `outdated`, it drops out
        of knowledge-base search and Auto-Answer stops serving it, and an
        audit row is written. The person who gave the answer is notified,
        unless they are the one marking it. Only the web can mark it current
        again.

        Idempotent: an entry that is already outdated is returned unchanged
        (its first reason is kept) and nobody is notified again.

        Who may: business admins and Communications app admins. Anyone else
        gets `403 forbidden` — checked before the entry is looked up.

        **Required Scopes:** `destructive:news_feed` — retiring an answer is
        a delete-level act. A token without it gets
        `403 insufficient_permissions` ("Required scope: destructive:news_feed")
        before anything else is checked; a token a person makes by hand does
        not hold it. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The knowledge-base entry id.
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
                  description: Why the answer is outdated. Trimmed and cut to 500
                    characters; blank stores no reason.
                  example: The parking policy changed on October 1.
      responses:
        '200':
          description: The entry after the call
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - entry
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  entry:
                    "$ref": "#/components/schemas/NewsFeedKnowledgeBaseAdminEntry"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `destructive:news_feed` ("Required scope: destructive:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "Administrative access required." — the caller is not a business admin or a Communications app admin.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "The requested resource was not found." — no such
            entry in the business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`validation_failed` "Couldn''t update the entry: …" — the
            stored entry no longer passes its own validations, so it was left as it
            was.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/smes":
    get:
      tags:
      - News Feed
      summary: List the experts on a topic
      description: |
        The subject-matter experts for one topic: the people an admin
        assigned to it on the SMEs page first (oldest assignment first,
        `designation: assigned`), then the people the weekly scoring ranks
        as experts (highest score first, `designation: measured`), without
        repeats, up to `limit`. People who opted out of the topic are never
        listed, and nor is anyone no longer a member of the business. A topic
        id with no experts (or not in the business) returns an empty list.

        Who may: decided by the Communications setting `sme_tab_visibility` —
        `all_employees` (the default; anyone), `managers_and_above` (managers,
        business admins and Communications app admins) or `admins_only`
        (business admins and Communications app admins). Anyone else gets
        `403 forbidden`, checked before `topic_id`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: topic_id
        in: query
        required: true
        description: The topic id. Missing, zero, non-numeric or not a single value
          is a `400`.
        schema:
          type: integer
      - name: limit
        in: query
        description: Most experts to return. Missing or 0 means 5; otherwise clamped
          to 1..50.
        schema:
          type: integer
          default: 5
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: The topic's experts
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedSmeExpert"
        '400':
          description: '`invalid_request` "topic_id is required."'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "The experts directory is restricted for this workspace." — `sme_tab_visibility` does not include the caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/smes/by_topic":
    get:
      tags:
      - News Feed
      summary: List the experts on several topics
      description: |
        The same answer as `GET /news-feed/smes` for up to 50 topics at once,
        at most 5 experts per topic. The `data` object is keyed by topic id
        (as a string), in the order the ids were sent; a topic with no
        experts maps to an empty array. Duplicate, zero and non-numeric ids
        are dropped, and ids past the 50th are ignored. No ids → `{ "data": {} }`.

        Who may: the `sme_tab_visibility` rule described on `GET /news-feed/smes`.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: topic_ids[]
        in: query
        description: Topic ids, repeated (`topic_ids[]=3&topic_ids[]=7`). At most
          50 are read.
        style: form
        explode: true
        schema:
          type: array
          maxItems: 50
          items:
            type: integer
      responses:
        '200':
          description: Experts per topic
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    type: object
                    description: Topic id (string) → that topic's experts.
                    additionalProperties:
                      type: array
                      items:
                        "$ref": "#/components/schemas/NewsFeedSmeExpert"
                    example:
                      '3':
                      - id: 88
                        name: Ana Lopez
                        title: Facilities Lead
                        topic_id: 3
                        designation: assigned
                      '7': []
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "The experts directory is restricted for this workspace." — `sme_tab_visibility` does not include the caller.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/smes/gaps":
    get:
      tags:
      - News Feed
      summary: List knowledge gaps (admin)
      description: |
        Every active topic people posted about in the last 30 days, with
        how many experts it has, computed at the time of the request — the
        same list the Experts page's "Topics that need experts" is drawn
        from, so the two always agree. `high` severity first, then `medium`,
        then `low`; within each, fewest experts, then most posts, then name.
        Not paginated.

        Severity: `high` — no expert and 5 or more posts; `medium` — no
        expert and 1–4 posts, or one expert and 5 or more posts; `low` —
        everything else. `needs_experts` is true for `high` and `medium`,
        the rows the Experts page lists. Experts counts both the people an
        admin added on the Experts page and those found automatically.

        Who may: business admins and Communications app admins. Anyone else
        gets `403 forbidden`. (The `sme_tab_visibility` setting does not
        apply here.)

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The business's knowledge gaps
          content:
            application/json:
              schema:
                type: object
                required:
                - data
                properties:
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NewsFeedKnowledgeGap"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `forbidden` "Admin access required." — the caller is not a business admin or a Communications app admin.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/user-preferences/cc":
    get:
      tags:
      - News Feed
      summary: Get the caller's closed-captions preference
      description: |
        The caller's closed-captions preference for feed videos and what it
        resolves to. `auto` resolves against the caller's language — their
        profile locale, else the first tag of the `Accept-Language` header:
        a language other than English turns captions on; English, or no
        language at all, leaves them off.

        The preference row is created (as `auto`) on the first read, so this
        GET can write.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller's preference
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedCcPreference"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - News Feed
      summary: Set the caller's closed-captions preference
      description: |
        Sets the caller's closed-captions preference and answers it with its
        resolved value (see GET).

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - cc_preference
              properties:
                cc_preference:
                  type: string
                  enum:
                  - auto
                  - 'on'
                  - 'off'
      responses:
        '200':
          description: The caller's preference after the change
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedCcPreference"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`invalid_value` "cc_preference must be one of auto, on, off"
            — `cc_preference` is missing or not one of the three values. The sentence
            is also repeated in a top-level `message`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/user-preferences/read-aloud":
    get:
      tags:
      - News Feed
      summary: Get the caller's Read Aloud preferences
      description: |
        The caller's own Read Aloud voice and speed. Each is `null` when the
        caller has not chosen one — the player then uses the admin's default
        voice (else `neutral`) and normal speed (1.0).

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller's Read Aloud preferences
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedReadAloudPreference"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
      - News Feed
      summary: Set the caller's Read Aloud preferences
      description: |
        Changes the keys sent and leaves the others alone. Sending a key as
        `null` (or an empty string) clears the caller's choice, falling back
        to the default. Nothing is saved when either value is invalid.

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tts_voice:
                  type: string
                  nullable: true
                  enum:
                  - neutral
                  - male
                  - female
                  -
                read_aloud_speed:
                  type: number
                  nullable: true
                  enum:
                  - 0.75
                  - 1.0
                  - 1.25
                  - 1.5
                  - 2.0
                  -
                  description: A number or numeric string. A list (`read_aloud_speed[]=…`)
                    is refused with `422`.
            example:
              tts_voice: female
              read_aloud_speed: 1.25
      responses:
        '200':
          description: The caller's Read Aloud preferences after the change
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedReadAloudPreference"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: 'A value was refused. The body is NOT the error envelope: it
            is `{ "errors": { "<field>": ["<message>"] } }` — `tts_voice` "must be
            one of neutral, male, female or null", `read_aloud_speed` "must be one
            of 0.75, 1.0, 1.25, 1.5, 2.0", or a stored-row validation message under
            the model''s field name.'
          content:
            application/json:
              schema:
                type: object
                required:
                - errors
                properties:
                  errors:
                    type: object
                    additionalProperties:
                      type: array
                      items:
                        type: string
                example:
                  errors:
                    read_aloud_speed:
                    - must be one of 0.75, 1.0, 1.25, 1.5, 2.0
  "/news-feed/voice/transcribe":
    post:
      tags:
      - News Feed
      summary: Upload a voice recording for transcription
      description: |
        Whisper-mode voice-to-text for the composer and comments. Uploads the
        recording, creates a pending transcript and queues the transcription;
        the answer is `202` with the transcript's id. The result arrives on
        the `VoiceTranscriptChannel` broadcast, or poll
        `GET /news-feed/voice/{id}`. The audio is deleted once transcribed.

        Transcription draws the business's AI credits. A business whose AI
        usage is capped, a recording with no speech in it, or a transcription
        that fails three times ends with the transcript `failed` and a
        `failure_reason` — the upload itself still answers `202`.

        Needs the Communications setting `voice_sharing` on (else `422
        voice_sharing_disabled`) and `voice_transcription_mode` set to
        `whisper` (else `422 whisper_mode_disabled`; in `native` mode the
        device transcribes and never calls this).

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - audio
              properties:
                audio:
                  type: string
                  format: binary
                  description: The recording, at most 25 MB, of type `audio/webm`,
                    `audio/mpeg`, `audio/mp4`, `audio/m4a`, `audio/x-m4a`, `audio/wav`
                    or `audio/ogg` (the part's declared content type).
      responses:
        '202':
          description: The transcript was created and its transcription queued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedVoiceTranscript"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `voice_sharing_disabled` — voice sharing is off for the business.
            - `whisper_mode_disabled` — the business transcribes on the device (`native` mode).
            - `missing_file` "Audio file is required".
            - `file_too_large` "File exceeds 25 MB".
            - `unsupported_mime` "Unsupported audio type: <type>".

            The upload refusals also repeat the sentence in a top-level `message`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: '`storage_error` "Failed to upload audio" — the recording could
            not be stored; try again. No transcript is kept for it.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/voice/{id}":
    get:
      tags:
      - News Feed
      summary: Get a voice transcript
      description: |
        One of the caller's own voice transcripts — the polling fallback for
        a client that missed the broadcast. Only the person who uploaded the
        recording may read it; anyone else's id, like a missing one, is `404`.

        Needs the Communications setting `voice_sharing` on (else `422
        voice_sharing_disabled`). Unlike the upload, it does not need
        `whisper` mode.

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: The transcript id (digits only; anything else does not route).
        schema:
          type: integer
      responses:
        '200':
          description: The transcript
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedVoiceTranscript"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — no transcript with this id that the caller uploaded
            in this business."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`voice_sharing_disabled` — voice sharing is off for the business."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/tts/generate":
    post:
      tags:
      - News Feed
      summary: Get Read Aloud audio for a post
      description: |
        A playable audio file of a post's body, read aloud. The audio for a
        post, voice and language is generated once and cached for 24 hours;
        a cache hit (`cached: true`) charges nobody, a miss draws the
        caller's business's AI credits. Editing the post's text discards its
        cached audio.

        `audio_url` is a signed link valid for **1 hour**; `expires_at` is
        when that link stops working (the hour, or sooner when the cached
        audio expires first), so call again for a fresh link by then.

        The caller must be able to read the post: its audience (or its
        author) once it has gone out; the people preparing it while it is a
        draft or scheduled. Any other post — or no `feed_id` — is `404`, so
        the answer never says a post exists.

        `voice` defaults to the caller's own Read Aloud choice, else the
        admin's default, else `neutral`.

        **What it speaks is the language the reader is shown the post in**,
        decided by the server with the feed's own rule: the reader's language
        when the company offers it and its translation of this post is ready;
        otherwise the original (also for the reader's own post, a draft, or a
        post they chose "Show original" on). The response's `language` says
        which, and `title` is the headline in that language. A `language` the
        caller sends is honoured only when it is `source` (the original) or one
        of the company's offered languages; any other value is ignored.

        Needs the Communications setting `read_aloud` on (else `422
        read_aloud_disabled`).

        **Required Scopes:** `write:news_feed` (it is a POST). A token without
        it gets `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - feed_id
              properties:
                feed_id:
                  type: integer
                  description: The post (feed) id.
                voice:
                  type: string
                  enum:
                  - neutral
                  - male
                  - female
                language:
                  type: string
                  description: Optional. `source` for the original, or one of the
                    company's offered language codes (as the post page's language
                    picker); anything else is ignored and the reader's own rule decides.
                  example: es
      responses:
        '200':
          description: The audio for the post
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedTtsAudio"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`not_found` — no such post in the business, or one the caller
            may not read (including a draft or scheduled post they are not preparing)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `read_aloud_disabled` — Read Aloud is off for the business.
            - `invalid_voice` "voice must be one of neutral, male, female".
            - `no_readable_content` "Feed has no readable text" — the post has no body text to read.
            - `ai_usage_blocked` — the business's (or the caller's) AI credits are used up; the message is the policy's sentence.

            The service refusals also repeat the sentence in a top-level `message`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: '`tts_unavailable` — speech generation failed, or the audio
            was generated but no playable link could be signed. Either way the `message`
            reads "Read Aloud isn''t available right now. Try again in a moment."
            Retry later.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/ai/enhance":
    post:
      tags:
      - News Feed
      summary: Rewrite a draft post or comment with AI
      description: |
        Rewrites a draft for grammar, clarity and tone — the composer's
        "Enhance with AI" button. Nothing is saved; the rewritten text is
        returned for the author to accept or reject. The call draws the
        business's AI credits.

        The input limit is the server's, never the client's: 1,000
        characters for a comment (`content_type: comment`), 5,000 for
        anything else; the rewrite is cut to the same limit. The draft must
        hold at least 20 non-blank characters.

        Each person may call it 120 times an hour. Past that the call
        answers **`200`** with `{ "ok": false, "error": "<sentence>",
        "code": "rate_limited", "retry_at": "<when it lifts>" }` and no
        `enhanced_text`.

        Needs the Communications setting `ai_enhanced_authoring` on (else
        `403 feature_disabled`, checked before the rate limit and the body).

        **Required Scopes:** `write:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              properties:
                text:
                  type: string
                  description: The draft. Missing or blank is a `400`.
                tone:
                  type: string
                  enum:
                  - professional
                  - friendly
                  - urgent
                  - concise
                  default: professional
                  description: Any other value means `professional`.
                content_type:
                  type: string
                  enum:
                  - update
                  - question
                  - poll
                  - comment
                  default: update
                  description: What is being written. Any other value means `update`.
                instruction:
                  type: string
                  enum:
                  - fix_grammar
                  - shorter
                  - longer
                  description: 'A narrower rewrite: `fix_grammar` (correctness only),
                    `shorter` or `longer`. Omitted or unknown means the default enhance.'
                writing_profile_id:
                  type: integer
                  description: A ready writing profile of this business whose trained
                    voice the rewrite should follow. An id that is not one is ignored.
            example:
              text: hey team the parking lot B will be closed tmrw for repaving so
                pls use lot C
              tone: friendly
              content_type: update
      responses:
        '200':
          description: 'The rewritten text — or, past the hourly limit, the rate-limit
            body (`ok: false`, `code: rate_limited`).'
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  required:
                  - enhanced_text
                  properties:
                    enhanced_text:
                      type: string
                - "$ref": "#/components/schemas/NewsFeedAiRateLimited"
        '400':
          description: "`parameter_missing` — `text` is missing or blank."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
            - `feature_disabled` — AI-enhanced authoring is off for the business.
            - `ai_blocked` — the business's (or the caller's) AI usage policy refused the call; the message is the policy's sentence, also repeated in a top-level `message`. Not transient: do not retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: |
            - `empty_text` — fewer than 20 non-blank characters.
            - `over_input_limit` — longer than 1,000 characters (comment) or 5,000 (anything else).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: "`service_error` — the AI returned nothing; try again."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/ai/suggest_topics":
    post:
      tags:
      - News Feed
      summary: Suggest topics for a draft post
      description: |
        Up to 3 topic suggestions for the composer's "AI suggests #x #y"
        hint. Nothing is saved. Suggestions are topic slugs; when the
        Communications setting that allows open topics is off, only topics in
        the business's active taxonomy are offered.

        Never an error for the author: the call answers `200` with an empty
        list and `source: disabled` when the Communications settings
        `ai_auto_topic` or `author_topic_suggestions_in_composer` is off, or
        when the draft holds fewer than 50 non-blank characters; and with
        `source: error` when the AI call fails or times out.

        **Required Scopes:** `write:news_feed` (it is a POST). A token without
        it gets `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              properties:
                text:
                  type: string
                  description: The draft. Missing or blank is a `400` — but only once
                    both topic settings are on.
                segment_id:
                  type: string
                  description: 'The audience the draft is aimed at, as context for
                    the AI: `direct_reports`, or a recipient-group id of this business.
                    Anything else is ignored.'
      responses:
        '200':
          description: The suggestions (possibly none)
          content:
            application/json:
              schema:
                type: object
                required:
                - suggestions
                - source
                properties:
                  suggestions:
                    type: array
                    maxItems: 3
                    items:
                      type: string
                      description: A topic slug.
                      example: parking
                  source:
                    type: string
                    enum:
                    - taxonomy
                    - open
                    - mixed
                    - empty
                    - disabled
                    - error
                    description: |
                      - `taxonomy` / `open` / `mixed` — the suggestions came from the business's topics, new topics, or both.
                      - `empty` — the AI found nothing confident.
                      - `disabled` — a topic setting is off, or the draft is under 50 characters.
                      - `error` — the AI call failed or timed out.
        '400':
          description: "`parameter_missing` — `text` is missing or blank (only checked
            once the feature is on)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/events":
    post:
      tags:
      - News Feed
      summary: Record a client analytics event
      description: |
        A fire-and-forget beacon for events only the client sees (a video
        that froze, a reply preview tapped, a Read Aloud play, an AI enhance
        accepted …). Answers `204` with no body. Only the event names listed
        are accepted; events the server can see for itself are recorded
        server-side and refused here.

        `feed_id` is optional. When sent, the caller must be able to read the
        post (else `404`); an event about a draft or scheduled post is
        dropped with a `204` and not recorded.

        Event properties are read from the body by event: voice events
        (`voice_*`) take `surface`, `mode`, `duration_ms_to_transcript`,
        `char_count`, `chars_changed`; Read Aloud events (`tts_play_*`) take
        `voice`, `language`, `speed`, `fraction_played`, `reason`; enhance
        events (`enhance_*`, `accept_all`, `reject_all`) take `content_type`,
        `tone`, `duration_ms`; `reply_preview_tapped` takes
        `new_replies_count`, `seconds_since_last_seen`;
        `news_feed.poll.voted` takes `option_count`, `voting_mode`,
        `is_change`; `news_feed.poll.chart_render_error` takes
        `error_message`; `video_cc_toggled` takes `feed_media_id`, `enabled`;
        `media_load_error` and `mobile_video_freeze` take `feed_media_id`,
        `error_code`, `network_type`. Strings are cut short (16–200
        characters), anything else is ignored, and the caller's user agent is
        recorded with every event.

        **Required Scopes:** `write:news_feed` (it is a POST). A token without
        it gets `403 insufficient_permissions` ("Required scope: write:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - event
              properties:
                event:
                  type: string
                  enum:
                  - media_load_error
                  - mobile_video_freeze
                  - reply_preview_tapped
                  - news_feed.poll.voted
                  - news_feed.poll.chart_updated_live
                  - news_feed.poll.chart_render_error
                  - news_feed.poll.presentation_started
                  - enhance_clicked
                  - enhance_returned
                  - accept_all
                  - reject_all
                  - enhance_timeout
                  - voice_record_started
                  - voice_transcribed
                  - voice_transcript_edited
                  - voice_post_published
                  - tts_play_started
                  - tts_play_completed
                  - tts_play_paused
                  - video_cc_toggled
                feed_id:
                  type: integer
                  description: The post the event is about, if any.
              additionalProperties: true
            example:
              event: tts_play_completed
              feed_id: 412
              voice: neutral
              fraction_played: 0.92
      responses:
        '204':
          description: Recorded (or, for a draft or scheduled post, dropped).
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `write:news_feed` ("Required scope: write:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`not_found` "Feed not found." — a `feed_id` was sent for a
            post that is not in the business or that the caller may not read.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: '`validation_failed` "unknown event" — `event` is missing or
            not one of the accepted names.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/news-feed/media/{media_id}/subtitles":
    get:
      tags:
      - News Feed
      summary: Get a video's subtitle status
      description: |
        Whether a feed video has auto-generated subtitles, and where to load
        them — the player asks this when a viewer opens a video, to decide
        whether to add a captions track. The answer is carried by the HTTP
        status and by `status` in the body (never the error envelope):

        - `200` `ready` — with `webvtt_url`, a WebVTT file (a signed link
          valid for an hour, or a CDN link).
        - `202` `pending` — subtitles are being generated; ask again later.
        - `422` `failed` — generation failed.
        - `404` `not_applicable` — this file gets no subtitles.
        - `404` `not_found` — no such live file in the business, or one on a
          post the caller may not read (the audience of a sent post; the
          people preparing a draft or scheduled one).

        **Required Scopes:** `read:news_feed`. A token without it gets
        `403 insufficient_permissions` ("Required scope: read:news_feed")
        before anything else is checked. A browser session is asked no scope.
      security:
      - BearerAuth: []
      parameters:
      - name: media_id
        in: path
        required: true
        description: The post attachment (feed media) id (digits only; anything else
          does not route).
        schema:
          type: integer
      responses:
        '200':
          description: Subtitles are ready
          content:
            application/json:
              schema:
                type: object
                required:
                - status
                - webvtt_url
                properties:
                  status:
                    type: string
                    enum:
                    - ready
                  webvtt_url:
                    type: string
                    format: uri
                    nullable: true
                    description: The WebVTT file; null if no link could be made for
                      it.
        '202':
          description: Subtitles are being generated
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedSubtitleStatus"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: |
            - `insufficient_permissions` — the token lacks `read:news_feed` ("Required scope: read:news_feed").
            - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: '`{ "status": "not_applicable" }` or `{ "status": "not_found"
            }` — see above.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedSubtitleStatus"
        '422':
          description: '`{ "status": "failed" }` — subtitle generation failed.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NewsFeedSubtitleStatus"
  "/inspections/inspections":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspections (personal or team)
      description: |
        Returns a paginated list of inspections, scoped role-aware:

        - **Default** (no `team` param or caller is not a manager): inspections
          the caller is the inspector for. Mirrors the desktop
          `/apps/inspections/inspections/my_inspections` action.
        - **`?team=true`** AND caller is `manager_or_above?` or app-admin for
          `inspections`: the team feed honoring the tenant's
          `team_inspections_scope` setting (location / department / none) via
          `Inspections::TeamScopeService`.

        A caller who passes `?team=true` but lacks manager / inspections-admin
        permission receives **403 Forbidden** — the request is rejected rather
        than silently downgraded to the personal feed, so clients learn they
        lack team visibility.

        **Ordering** depends on `status_type` (each value mirrors the
        corresponding desktop bucket action so the API and the web list agree):
        - `status_type=overdue` — earliest `due_at` first (most overdue at the
          top). Returns scheduled/in_progress inspections whose `due_at` is in
          the past.
        - `status_type=active` — **urgency order** on the personal feed:
          Overdue first, then In Progress, then Scheduled; within each group the
          soonest `due_at` first (NULLs last), then `created_at DESC` and `id
          DESC` as tiebreaks — a total order, so paging is stable and a row
          cannot repeat on one page and be skipped on the next. Returns
          inspections with `status IN (scheduled,
          in_progress)` — the rows counted by the personal-feed Active tab badge
          (`segment_counts.active` in this same response). Excludes `draft`
          (training-gated and not yet startable — reachable via the unfiltered
          list), `in_review` and `cancelled`. On the team feed (`?team=true`)
          `active` is not a tab and sorts by newest `created_at` first.
        - `status_type=in_progress` — most recently started first
          (`started_at DESC`, NULLs last).
        - `status_type=scheduled` — earliest scheduled first
          (`scheduled_at ASC`, NULLs last).
        - `status_type=in_review` — earliest `due_at` first (NULLs last).
        - `status_type=completed` — most recently completed first
          (`completed_at DESC`, NULLs last). Superset — includes passed,
          failed, and outcome-less rows.
        - `status_type=failed` — most recently completed first
          (`completed_at DESC`, NULLs last). Returns completed inspections
          that did not pass (`passed IS NOT TRUE` — i.e. `passed = false`
          OR `passed IS NULL`, matching the UI's "Failed" badge).
        - `status_type=passed` — most recently completed first
          (`completed_at DESC`, NULLs last). Returns completed inspections
          with `passed = TRUE` (strict — excludes outcome-less rows).
        - No `status_type` filter — ordering depends on the feed:
          - **Personal feed** (`team` omitted / `false`): the same **urgency
            order** as `status_type=active`, because the personal feed is a work
            queue and the most urgent work must land on page 1 rather than
            wherever `created_at` happens to put it. Every status is still
            returned (no filter is applied); `draft`, `in_review`, `completed`
            and `cancelled` simply sort after all actionable rows. Clients that
            narrow this list themselves therefore get overdue work first without
            having to send `status_type` or re-sort the page.
          - **Team feed** (`?team=true`): newest `created_at` first, matching
            the desktop "All" inspections page.
      parameters:
      - name: team
        in: query
        description: |
          Set to `true` to request the team feed. Requires manager-or-above
          or inspections app-admin permission; unauthorized callers get a
          403. Omit or set to `false` for the personal feed.
        schema:
          type: boolean
          default: false
      - name: status_type
        in: query
        description: |
          Semantic status bucket. Also drives result ordering (see endpoint
          description). Omit for all statuses.
          `overdue`     = scheduled/in_progress with due_at < now.
          `active`      = status IN (scheduled, in_progress) — matches the
                          personal-feed Active tab badge
                          (`segment_counts.active`). Excludes `draft`
                          (training-gated, not yet startable).
          `in_progress` = status=in_progress.
          `scheduled`   = status=scheduled.
          `in_review`   = status=in_review.
          `completed`   = status=completed (includes passed + failed +
                          outcome-less).
          `failed`      = status=completed AND `passed IS NOT TRUE`
                          (matches the UI's "Failed" badge).
          `passed`      = status=completed AND `passed = TRUE` (strict).
        schema:
          type: string
          enum:
          - overdue
          - active
          - in_progress
          - scheduled
          - in_review
          - completed
          - failed
          - passed
      - name: type
        in: query
        description: Filter by inspection template category (case-insensitive).
        schema:
          type: string
      - name: template_id
        in: query
        description: Filter by exact inspection_template_id.
        schema:
          type: integer
      - name: location_id
        in: query
        description: |
          Team feed only. Restrict the team feed to a single location. Honored
          only when `team=true`, the tenant's `team_inspections_scope` is
          `location`, and the id is one the caller may filter by (see the
          `location_filter.options` block in the response — ids outside that
          set are ignored and the default "all my locations" scope is returned).
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: List of inspections (ordering depends on `status_type` — see
            description)
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSummary"
                  meta:
                    "$ref": "#/components/schemas/PaginationMeta"
                  location_filter:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionLocationFilter"
                    description: |
                      Present only for the team feed (`team=true`). The
                      permission-scoped set of locations the caller may filter
                      by, for the location dropdown. Absent on the personal feed.
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: Forbidden — Inspections app disabled, no access, or `team=true`
            without manager/admin permission
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Create an inspection
      description: |
        Creates a new inspection in `in_progress` state, with the caller as
        the inspector. Optionally accepts a batch of item updates and a
        `complete: true` flag for one-shot offline-drafted submissions —
        the inspection transitions to `completed` (or `in_review` if the
        template has an approval workflow) before responding.

        Pass an `Idempotency-Key` header so a retried POST returns the
        original response instead of creating a duplicate inspection.

        **Media may come in on this request as multipart file parts.** Each
        `media_uploads[]` entry carries its bytes as a `file` part instead of a
        `blob_signed_id` — one request, no pre-uploaded blob and nothing to
        clean up (see `POST /inspections/direct_uploads`, deprecated). Send the
        body as `multipart/form-data`, with the entries in order:
        `media_uploads[][item_id]` (or `[template_item_id]`),
        `media_uploads[][file]`, optional `media_uploads[][kind]`. A
        JSON-only call is unchanged.

        Refused files (unsupported type — checked against the real bytes, not
        the declared type — or over the per-type cap) fail the whole call with
        422 and nothing is written or stored.
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Client-generated UUID; retries with the same key return the original
          response.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCreateRequest"
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/InspectionCreateRequest"
              - type: object
                description: 'Same fields, form-encoded, plus the file parts: `media_uploads[][file]`
                  where a JSON body would carry `media_uploads[][blob_signed_id]`.'
      responses:
        '201':
          description: Inspection created (and optionally submitted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  corrective_action_handoff:
                    "$ref": "#/components/schemas/InspectionCorrectiveActionHandoff"
        '404':
          description: Template not found or inactive
        '422':
          description: 'Validation failed; `template_has_no_items` — the template
            has no item to ask yet, so no inspection starts from it; `upload_blocked`
            — the virus scan found malware in a `media_uploads[][blob_signed_id]`
            file (that upload is deleted and nothing is written); `upload_incomplete`
            — a referenced upload does not match its declared checksum. With `complete:
            true`, also the completion gates of the complete endpoint (`items_pending`,
            `note_required`, `failure_details_required`, …)'
        '503':
          description: "`scanner_unavailable` — uploads are paused while virus scanning
            is unavailable, or `storage_unavailable` — a referenced upload could not
            be read. Nothing was written; retry the call"
  "/inspections/inspections/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Inspection ID
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single inspection
      description: |
        Returns the full inspection payload — every item with its compliance
        status, attached `media_items` (photos + videos, polymorphic
        `MediaItem` rows), GPS, approval state, corrective actions, and
        metadata. (Replaces the legacy `photos: []` / `videos: []` keys;
        items now carry `media_items: [...]`, `photo_count`, `video_count`,
        and `media_count`.) Each item's `photo_required` is true when it must
        carry a photo before the inspection can be submitted — an item whose
        template sets `requires_photo`, or a required photo-answer item — the
        rule the submit enforces.
      responses:
        '200':
          description: Inspection detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '404':
          description: Inspection not found
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Update an inspection
      description: |
        Mirrors the desktop Edit Inspection form
        (`/apps/inspections/inspections/:id/edit`). The body's allowlist is the
        same set the web form exposes — `title`, `inspection_template_id`,
        `location_id`, `scheduled_at`, `due_at`, and `notes`. Fields outside
        the allowlist are silently dropped.

        **Template-change guard** (web parity): the desktop form disables the
        template select once the inspection is `in_progress` or `completed`.
        This endpoint enforces the same rule — submitting a different
        `inspection_template_id` in those states returns
        `422 template_locked`. Other states (draft, scheduled, in_review,
        cancelled) leave the field editable.

        Authority: the assigned inspector OR a manager / inspections-admin.
        Members cannot edit colleagues' inspections (403).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionUpdateRequest"
      responses:
        '200':
          description: Inspection updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller lacks modify permission
        '404':
          description: Inspection not found
        '422':
          description: Validation failed, invalid_template, invalid_location, or template_locked
    delete:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Delete an inspection
      description: |
        Removes the inspection (mirrors the desktop dropdown's Delete action).
        Authority matches the web: the assigned inspector OR a manager /
        inspections-admin. Members cannot delete colleagues' inspections.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      responses:
        '204':
          description: Inspection deleted
        '403':
          description: Forbidden — caller lacks modify permission
        '404':
          description: Inspection not found
        '422':
          description: Inspection could not be deleted (callback / FK constraint)
  "/inspections/inspections/{id}/start":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Transition draft/scheduled → in_progress
      description: |
        Only the assigned inspector can start an inspection. No-op if the
        inspection is already `in_progress` (returns 200 with the current
        state).
      responses:
        '200':
          description: Inspection started
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller is not the inspector
        '422':
          description: Inspection is not in a startable state
  "/inspections/inspections/{id}/complete":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Submit an inspection for review or final completion
      description: |
        Transitions the inspection to `completed` (templates without an
        approval workflow) or `in_review` (templates with an approval
        workflow). Accepts an optional base64-encoded PNG signature and
        inspector notes, plus an optional `corrective_actions` array to
        create or update follow-up actions atomically as part of the same
        completion (rolled back together with the state transition if any
        entry fails validation).

        A `corrective_actions` entry's `status` follows the rules of
        `PATCH /inspections/corrective_actions/{id}`: `cancelled` is never
        written, and a completed or cancelled action's status does not change
        (`completed` sent again for a completed action is a no-op). Such an entry is
        skipped whole and the rest of the request still saves (owner decision
        2026-10-05, #6883); the response lists it in
        `skipped_corrective_actions`. `completed` stamps the action's completion
        date, as the dedicated endpoint does. Where the company requires a photo
        to complete a corrective action, `completed` on an action with no photo
        is skipped the same way (`reason: completion_photo_required`), and a new
        action sent as `completed` is added open (`in_progress` with an assignee, else
        `pending`) and listed there too;
        complete it with its photo through
        `POST /inspections/corrective_actions/{id}/complete`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCompleteRequest"
      responses:
        '200':
          description: Inspection completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  skipped_corrective_actions:
                    "$ref": "#/components/schemas/InspectionSkippedCorrectiveActions"
                  corrective_action_handoff:
                    "$ref": "#/components/schemas/InspectionCorrectiveActionHandoff"
        '403':
          description: Forbidden — caller is not the inspector
        '422':
          description: |
            Inspection is not in a completable state, or `items_pending` —
            one or more items that block completion are still unanswered.
            Conditionally-hidden items (unmet `show_when`) and visible items
            whose `require_when` isn't currently satisfied are excluded from
            this gate; a visible item whose `require_when` IS satisfied must
            be answered. Details carry `pending_count` and `items` (`[{id, name}]`,
            the items still owed). Also
            `note_required` — an answered item the template asks a note for carries
            none (details `items: [{id, name}]`); `failure_details_required` — a
            failed item on a template that requires failure details still lacks
            one (details `items: [{id, name, missing: [...]}]`, `missing` naming
            what each lacks: `severity`, `comment`, `photo`, `action_item`).
  "/inspections/inspections/{id}/cancel":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Cancel an in-flight inspection
      description: |
        Marks the inspection as `cancelled`. Only the assigned inspector
        (or a manager) can cancel. A reason is recommended for the audit
        log but not enforced at the API layer.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCancelRequest"
      responses:
        '200':
          description: Inspection cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller cannot cancel this inspection
        '422':
          description: Inspection is already completed
  "/inspections/inspections/{id}/sync":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Batched draft save / offline sync for an in-flight inspection
      description: |
        Applies a batch of item updates + inspection-level notes/GPS to an
        in-progress inspection without transitioning state. Rejects writes
        when the inspection is completed, cancelled, or in_review (409).
        Pass `complete: true` to optionally call `submit!` after applying
        updates. Used by the native client's Save button (draft persistence)
        and by the offline-sync flow when buffered edits are flushed.

        Also accepts a `corrective_actions` array to create / update
        follow-up actions atomically alongside the item writes — invalid
        entries (cross-tenant `assigned_to_id`, off-inspection
        `inspection_item_id`, bad priority / status) roll back the entire
        sync (422) rather than producing partial writes.

        A `corrective_actions` entry's `status` follows the rules of
        `PATCH /inspections/corrective_actions/{id}`: `cancelled` is never
        written, and a completed or cancelled action's status does not change
        (`completed` sent again for a completed action is a no-op). Such an entry is
        skipped whole and the rest of the sync still saves (owner decision
        2026-10-05, #6883); the response lists it in
        `skipped_corrective_actions`, and an Idempotency-Key replay returns the
        same list. `completed` stamps the action's completion date, as the
        dedicated endpoint does. Where the company requires a photo to complete
        a corrective action, `completed` on an action with no photo is skipped
        the same way (`reason: completion_photo_required`), and a new action
        sent as `completed` is added open (`in_progress` with an assignee, else
        `pending`) and listed there too.

        **Media may come in on this request as multipart file parts.** Each
        `media_uploads[]` entry carries its bytes as a `file` part instead of a
        `blob_signed_id` — one request, no pre-uploaded blob and nothing to
        clean up (see `POST /inspections/direct_uploads`, deprecated). Send the
        body as `multipart/form-data`, with the entries in order:
        `media_uploads[][item_id]` (or `[template_item_id]`),
        `media_uploads[][file]`, optional `media_uploads[][kind]`. A
        JSON-only call is unchanged.

        Refused files (unsupported type — checked against the real bytes, not
        the declared type — or over the per-type cap) fail the whole call with
        422 and nothing is written or stored.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionSyncRequest"
          multipart/form-data:
            schema:
              allOf:
              - "$ref": "#/components/schemas/InspectionSyncRequest"
              - type: object
                description: 'Same fields, form-encoded, plus the file parts: `media_uploads[][file]`
                  where a JSON body would carry `media_uploads[][blob_signed_id]`.'
      responses:
        '200':
          description: Inspection updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  skipped_corrective_actions:
                    "$ref": "#/components/schemas/InspectionSkippedCorrectiveActions"
                  corrective_action_handoff:
                    "$ref": "#/components/schemas/InspectionCorrectiveActionHandoff"
                  conflicts:
                    type: array
                    description: Present only when the request carried a `base_version`.
                      Each record whose server row changed after the client's base;
                      its fields were NOT applied.
                    items:
                      type: object
                      required:
                      - type
                      - id
                      - server_version
                      - message
                      properties:
                        type:
                          type: string
                          enum:
                          - inspection
                          - item
                        id:
                          type: integer
                        server_version:
                          type: string
                          description: The record's token as of this response (the
                            same value as its `server_version` in `inspection`) —
                            rebase on it.
                        message:
                          type: string
                  completion_deferred:
                    type: boolean
                    description: 'True when `complete: true` was sent but conflicts
                      prevented submission. The response is cached under its Idempotency-Key,
                      so the rebased retry needs a new key.'
        '403':
          description: Forbidden — caller is not the inspector
        '409':
          description: Inspection is locked (completed / cancelled / in_review)
        '422':
          description: |
            Validation failure on one of the items, or (with `complete: true`)
            `items_pending`, `note_required` or `failure_details_required` —
            see the complete endpoint; conditionally-hidden and
            not-currently-required items don't block. Also
            `invalid_base_version` when any `base_version` in the body is not
            an ISO-8601 string (checked before anything is written or stored).
            `upload_blocked` when the virus scan finds malware in a
            `media_uploads[][blob_signed_id]` file (that upload is deleted and
            nothing in the batch is written); `upload_incomplete` when such an
            upload does not match its declared checksum (upload it again).
        '503':
          description: "`scanner_unavailable` — uploads are paused while virus scanning
            is unavailable, or `storage_unavailable` — a referenced upload could not
            be read. Nothing was written; retry the call"
  "/inspections/inspections/{id}/take_over":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Take over (or pick up) an inspection
      description: |
        Reassigns an in-progress inspection to the current caller (takeover),
        or a scheduled one that hasn't started when the tenant allows pickup.
        All captured answers/photos/notes are preserved and the displaced
        inspector is notified. Eligibility (`Inspection#takeable_by?`): a
        manager/admin or a peer assigned to the inspection's location who is
        not already the inspector, and who can view the inspection. The
        `can_take_over` flag on the inspection payload signals eligibility.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionTakeOverRequest"
      responses:
        '200':
          description: Inspection reassigned to the caller
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: Forbidden — caller cannot view or is not eligible to take over
            this inspection
        '422':
          description: Inspection can no longer be taken over (wrong state)
  "/inspections/inspections/{id}/report_pdf":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: include_photos
      in: query
      required: false
      schema:
        type: string
        enum:
        - 'true'
        - 'false'
        default: 'true'
      description: Pass `false` to omit photo evidence from the PDF.
    - name: include_corrective_actions
      in: query
      required: false
      schema:
        type: string
        enum:
        - 'true'
        - 'false'
        default: 'true'
    - name: include_audit_trail
      in: query
      required: false
      schema:
        type: string
        enum:
        - 'true'
        - 'false'
        default: 'true'
      description: Pass `false` to omit the GPS / signature / timestamp page.
    - name: include_ai_summary
      in: query
      required: false
      schema:
        type: string
        enum:
        - 'true'
        - 'false'
        default: 'true'
      description: Pass `false` to omit the AI-generated executive summary.
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Download the inspection report as a PDF
      description: |
        Streams the inspection report PDF (mirrors the desktop "Export PDF"
        button). Sections are toggleable via the `include_*` query params —
        photos, corrective actions, audit trail, and AI summary all default
        on. Returns `application/pdf` with a `Content-Disposition: attachment`
        filename of `inspection-report-<id>-<YYYYMMDD>.pdf`.
      responses:
        '200':
          description: PDF stream
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '404':
          description: Inspection not found
        '500':
          description: PDF generation failed
  "/inspections/templates":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspection templates
      description: |
        Returns paginated inspection templates available to the caller's
        business. By default only templates an inspection can be started from
        are returned (active, with at least one question switched on); include
        system-wide templates (business_id IS NULL) with `include_system=true`.
      parameters:
      - name: active_only
        in: query
        schema:
          type: boolean
          default: true
        description: Restrict to templates an inspection can be started from (active,
          with a question switched on). Template authors may pass false to list every
          template.
      - name: category
        in: query
        schema:
          type: string
        description: Exact category match (e.g. `safety`, `quality`, `equipment`).
      - name: q
        in: query
        schema:
          type: string
        description: Case-insensitive search across name and description.
      - name: include_system
        in: query
        schema:
          type: boolean
          default: false
        description: Include system templates (business_id IS NULL).
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Templates list
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/InspectionTemplateSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/inspections/templates/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a template with full item definitions
      description: |
        Returns the full template payload — every item the inspector will
        be asked to answer, with item-type metadata, options, min/max
        bounds, and the failure-prompt settings the form renderer needs.
      responses:
        '200':
          description: Template detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    "$ref": "#/components/schemas/InspectionTemplateDetail"
        '404':
          description: Template not found
  "/inspections/corrective_actions":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List corrective actions
      description: |
        Returns paginated corrective actions. Default scope is "assigned to
        me"; managers can opt into the team scope via `?team=true`.

        The personal ("assigned to me") feed defaults to **open** actions
        (`pending` + `in_progress`) when no `status`/`overdue` filter is
        given — mirroring the desktop My Actions list. This keeps a completed
        action off the Active tab once it has been marked complete. Pass an
        explicit `status` (including `status=completed` for the Completed tab,
        or `status=all` for every status) to opt out of that default. The team
        scope is never narrowed this way.
      parameters:
      - name: assigned_to_me
        in: query
        schema:
          type: boolean
          default: true
        description: |
          When false, also surfaces actions the caller created (non-manager
          fallback). Ignored when `team=true` resolves to manager scope.
      - name: for_inspection_id
        in: query
        schema:
          type: integer
        description: Restrict to actions on a specific inspection.
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          - open
          - closed
          - all
        description: |
          Raw enum (`pending`/`in_progress`/`completed`/`cancelled`) or a
          semantic segment: `open` (pending + in_progress — the Active tab),
          `closed` (completed + cancelled), or `all` (every status, opts out
          of the personal open-by-default).
      - name: overdue
        in: query
        schema:
          type: boolean
        description: Only open actions past their due_date.
      - name: team
        in: query
        schema:
          type: boolean
          default: false
        description: |
          Manager-only. Widens the scope across the team. Non-managers
          who pass `team=true` silently get the personal scope.
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
        description: Items per page. Values outside 1–100 are clamped, not refused.
      responses:
        '200':
          description: Corrective actions list
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/InspectionCorrectiveAction"
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/CollectionPaginationMeta"
                      - type: object
                        properties:
                          segment_counts:
                            type: object
                            description: |
                              Badge counts for the native My Actions chips,
                              computed from the same assignee
                              scope as `items` but ignoring the current
                              `status`/`overdue` filter. `active` and `open`
                              are the same number (pending + in_progress);
                              `closed` is completed + cancelled; `overdue`
                              is open actions past their due date.
                            properties:
                              all:
                                type: integer
                              active:
                                type: integer
                              open:
                                type: integer
                              pending:
                                type: integer
                              in_progress:
                                type: integer
                              completed:
                                type: integer
                              overdue:
                                type: integer
                              closed:
                                type: integer
                              cancelled:
                                type: integer
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: "`invalid_status` — `status` is not one of the accepted values"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: |
            - `app_disabled` — Inspections is not enabled for the caller's business
            - `app_access_denied` — the caller does not meet the Inspections app's visibility rules
            - `insufficient_permissions` — the token lacks `read:inspections` ("Required scope: read:inspections")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Inspections marketplace app is not configured
            on this install"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/inspections/corrective_actions/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single corrective action
      responses:
        '200':
          description: Corrective action detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '404':
          description: Not found
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Update a corrective action
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/InspectionCorrectiveActionUpdateRequest"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '403':
          description: Forbidden — the caller may not update this corrective action,
            or (`ownership_change_forbidden`) is its assignee changing its due date
            or priority, or leaving it unassigned, which only a manager, the inspection's
            inspector or whoever raised it may do
        '404':
          description: Not found
        '422':
          description: Validation failed
  "/inspections/corrective_actions/{id}/complete":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Mark a corrective action complete
      description: |
        When the company requires a photo to complete a corrective action
        (Settings › Inspections, on by default), the action must carry a photo
        of the completed work: send it as the multipart `completion_photo` on
        this request, or have one already attached. Without it the answer is
        `422 completion_photo_required`. The same rule applies to
        `PATCH /inspections/corrective_actions/{id}` with `status: completed`
        (which takes the multipart `completion_photo` too), and an action cannot
        be created already completed while it applies. A `sync` / `complete`
        batch skips such an entry and lists it in `skipped_corrective_actions`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolution_notes:
                  type: string
                  nullable: true
          multipart/form-data:
            schema:
              type: object
              properties:
                resolution_notes:
                  type: string
                  nullable: true
                completion_photo:
                  type: string
                  format: binary
                  description: Photo of the completed work
      responses:
        '200':
          description: Completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '403':
          description: Forbidden
        '404':
          description: Not found
        '422':
          description: Already closed (`cannot_complete`), a photo is required and
            none is attached (`completion_photo_required`), or the photo was refused
            (`completion_photo_invalid`)
  "/drive/config":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Read Drive configuration
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. The same settings and scope capabilities appear in desktop bootstrap.
        This request fetches no file bytes.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  drive:
                    "$ref": "#/components/schemas/DriveConfig"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/my_drive":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Find or provision My Drive
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Idempotently provisions the caller’s personal Drive on first use.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  drive:
                    "$ref": "#/components/schemas/Drive"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/shared":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: List items shared with the caller
      description: 'Requires read:drive; Drive must be enabled and accessible to the
        caller. '
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DriveItem"
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
  "/drive/recent":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: List recently updated accessible files
      description: 'Requires read:drive; Drive must be enabled and accessible to the
        caller. '
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DriveItem"
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
  "/drive/search":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Search accessible Drive items
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Results are permission-filtered. q requires at least two characters;
        names and content modes are explicit.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DriveItem"
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: q
        in: query
        schema:
          type: string
      - name: mode
        in: query
        schema:
          type: string
          enum:
          - names
          - content
  "/drive/people":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Find people for a direct share
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Returns at most 20 active people; q requires at least two characters.
        No email address is exposed.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  people:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          minimum: 1
                        name:
                          type: string
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: q
        in: query
        schema:
          type: string
  "/drive/drives":
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: List accessible drives
      description: 'Requires read:drive; Drive must be enabled and accessible to the
        caller. '
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  drives:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Drive"
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
  "/drive/drives/{drive_id}":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Read a drive
      description: 'Requires read:drive; Drive must be enabled and accessible to the
        caller. '
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  drive:
                    "$ref": "#/components/schemas/Drive"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: List folder items or Trash
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Listing is metadata only; no thumbnail or signed content URL is returned.
        Trash rows include deleted_at and restorable. Item shares can grant a folder
        without access to the drive root.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DriveItem"
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: parent_id
        in: query
        schema:
          type: string
      - name: trashed
        in: query
        schema:
          type: boolean
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Create a folder
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Requires editor on the destination. Native file bytes use upload sessions
        with purpose drive.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              - kind
              properties:
                name:
                  type: string
                kind:
                  type: string
                parent_id:
                  type: integer
                  nullable: true
  "/drive/drives/{drive_id}/items/{id}":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Read item metadata
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Requires viewer on the item. Selecting a file does not record a preview
        or download.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
    patch:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Rename an item
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Requires editor; a conflicting sibling name is refused.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
    delete:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Move an item to Trash
      description: Requires destructive:drive; Drive must be enabled and accessible
        to the caller. Requires editor and applies the configured retention policy.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/restore":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Restore an item from Trash
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Requires editor. A deleted ancestor returns 409; expiry returns 422;
        name collisions return renamed_from.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/preview":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Explicitly preview file content
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Requires viewer. The original blob scan gate runs before any bytes
        or URL are returned (423 while held). Text is escaped client-side and capped
        at 262144 bytes; supported media get a short-lived URL. Unsupported formats
        return kind web. Only an actual preview records previewed activity.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  preview:
                    "$ref": "#/components/schemas/DrivePreview"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/download":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Resolve an explicit file download
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Requires viewer and a clear scan verdict (423 otherwise). The URL,
        version_id and byte_size describe the same immutable blob. Native editing
        binds its base version to this response and validates the received byte count.
        Never send API bearer credentials to the returned storage URL.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
                  version_id:
                    type: integer
                    minimum: 1
                  byte_size:
                    type: integer
                    minimum: 0
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/move":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Move an item
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Destination editor required; an explicit null parent means root. Move
        requires source editor and stays within one drive; copy requires source viewer
        and may cross drives. Cycles and name collisions are refused by the canonical
        item mover.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - destination_parent_id
              properties:
                destination_drive_id:
                  type: integer
                  minimum: 1
                destination_parent_id:
                  type: integer
                  nullable: true
  "/drive/drives/{drive_id}/items/{id}/copy":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Copy an item
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Destination editor required; an explicit null parent means root. Move
        requires source editor and stays within one drive; copy requires source viewer
        and may cross drives. Cycles and name collisions are refused by the canonical
        item mover.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - destination_parent_id
              properties:
                destination_drive_id:
                  type: integer
                  minimum: 1
                destination_parent_id:
                  type: integer
                  nullable: true
  "/drive/drives/{drive_id}/items/{id}/memberships":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Read direct and inherited access
      description: 'Requires read:drive; Drive must be enabled and accessible to the
        caller. '
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  memberships:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          minimum: 1
                        principal_type:
                          type: string
                        principal_id:
                          type: integer
                          minimum: 1
                        principal_name:
                          type: string
                        role:
                          type: string
                          enum:
                          - viewer
                          - commenter
                          - editor
                          - manager
                        inherited:
                          type: boolean
                        no_access_reason:
                          type: string
                          nullable: true
                          enum:
                          - left
                          - deleted
                          -
                  inherited_audiences:
                    type: array
                    items:
                      type: string
                      enum:
                      - everyone
                      - workspace_owners
                      - workspace_members
                  inherited_memberships:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        principal_type:
                          type: string
                        principal_id:
                          type: integer
                          minimum: 1
                        principal_name:
                          type: string
                        role:
                          type: string
                          enum:
                          - viewer
                          - commenter
                          - editor
                          - manager
                        inherited:
                          type: boolean
                        no_access_reason:
                          type: string
                          nullable: true
                          enum:
                          - left
                          - deleted
                          -
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Grant or change direct access
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Requires manager. Chat-room viewer grants are made only through send_to_chat
        with explicit grant_access.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  membership:
                    type: object
                    properties:
                      id:
                        type: integer
                        minimum: 1
                      principal_type:
                        type: string
                      principal_id:
                        type: integer
                        minimum: 1
                      principal_name:
                        type: string
                      role:
                        type: string
                        enum:
                        - viewer
                        - commenter
                        - editor
                        - manager
                      inherited:
                        type: boolean
                      no_access_reason:
                        type: string
                        nullable: true
                        enum:
                        - left
                        - deleted
                        -
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                principal_type:
                  type: string
                  enum:
                  - User
                  - NotificationRecipientGroup
                principal_id:
                  type: integer
                  minimum: 1
                role:
                  type: string
  "/drive/drives/{drive_id}/items/{id}/memberships/{membership_id}":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: membership_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    delete:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Revoke direct access
      description: Requires destructive:drive; Drive must be enabled and accessible
        to the caller. Requires manager. Inherited access is managed at its source;
        another grant may still allow access.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/external_links":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: List external links
      description: Requires read:drive; Drive must be enabled and accessible to the
        caller. Requires manager.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  external_links:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          minimum: 1
                        public_url:
                          type: string
                          format: uri
                        expires_at:
                          type: string
                          format: date-time
                        download_count:
                          type: integer
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Create an external viewer link
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Requires manager and the external-sharing capability. Password and
        lifetime requirements come from configuration and are rechecked server-side.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  external_link:
                    type: object
                    properties:
                      id:
                        type: integer
                        minimum: 1
                      public_url:
                        type: string
                        format: uri
                      expires_at:
                        type: string
                        format: date-time
                      download_count:
                        type: integer
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                external_link:
                  type: object
                  properties:
                    role:
                      type: string
                      enum:
                      - viewer
                    password:
                      type: string
                    expires_at:
                      type: string
                      format: date-time
                    download_limit:
                      type: integer
                      minimum: 1
  "/drive/drives/{drive_id}/items/{id}/external_links/{link_id}":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: link_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    delete:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Revoke an external link
      description: Requires destructive:drive; Drive must be enabled and accessible
        to the caller. Requires manager.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/versions":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    get:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: List version history
      description: 'Requires read:drive; Drive must be enabled and accessible to the
        caller. '
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/DriveVersion"
                  pagination:
                    "$ref": "#/components/schemas/DrivePagination"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      parameters:
      - name: page
        in: query
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          minimum: 1
  "/drive/drives/{drive_id}/items/{id}/versions/{version_id}/restore":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: version_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Restore a previous version
      description: Requires write:drive; Drive must be enabled and accessible to the
        caller. Requires editor and readable stored bytes. Restoring changes the current
        version without creating or pruning a version.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/DriveItem"
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
  "/drive/drives/{drive_id}/items/{id}/chat_reach":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Check who can open a file in selected chats
      description: Requires write:drive and write:chat; Drive must be enabled and
        accessible to the caller. Requires membership and permission to post in every
        selected chat. Missing-reader counts are exact and include current Drive-app
        access. This read never grants access.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  rooms:
                    type: array
                    items:
                      type: object
                      properties:
                        room_id:
                          type: integer
                          minimum: 1
                        missing_count:
                          type: integer
                          minimum: 0
                        can_grant:
                          type: boolean
                        manager_name:
                          type: string
                          nullable: true
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - room_ids
              properties:
                room_ids:
                  type: array
                  minItems: 1
                  maxItems: 5
                  items:
                    type: integer
                    minimum: 1
  "/drive/drives/{drive_id}/items/{id}/send_to_chat":
    parameters:
    - name: drive_id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    - name: id
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Send a Drive file link to chats
      description: Requires write:drive and write:chat; Drive must be enabled and
        accessible to the caller. The same request_id is idempotent per sender/file/chat.
        Partial refusals are returned per chat. Sending alone never changes access.
        Explicit grant_access requires file manager and grants viewer to current members
        of each chat; leaving that chat removes this route to access. The durable
        message stores file metadata only, alongside a normal same-tenant web link.
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent:
                    type: array
                    items:
                      type: object
                      properties:
                        room_id:
                          type: integer
                          minimum: 1
                  refused:
                    type: array
                    items:
                      type: object
                      properties:
                        room_id:
                          type: string
                        reason:
                          type: string
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - room_ids
              - request_id
              properties:
                room_ids:
                  type: array
                  minItems: 1
                  maxItems: 5
                  items:
                    type: integer
                    minimum: 1
                request_id:
                  type: string
                  minLength: 16
                  maxLength: 100
                note:
                  type: string
                  maxLength: 1000
                grant_access:
                  type: boolean
                  default: false
  "/drive/save_attachment":
    post:
      tags:
      - Drive
      security:
      - BearerAuth: []
      summary: Save an independent chat attachment copy
      description: Requires write:drive and read:chat; Drive must be enabled and accessible
        to the caller. Requires current chat membership, an available unmasked message
        and editor on the destination. A background job streams, rescans and stores
        a new independent blob. Poll the returned upload session; no file bytes traverse
        the renderer. Reuse request_id for an ambiguous retry; a terminal failure
        can be retried with a fresh request id.
      responses:
        '202':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_session:
                    type: object
                    properties:
                      token:
                        type: string
                      status:
                        type: string
                        enum:
                        - open
                        - composing
                        - completed
                        - failed
                        - expired
                        description: |
                          `open` (accepting parts) → `composing` (assembling) → `completed` or
                          `failed`. `expired` is terminal too: the session ran past
                          `expires_at` before it finished, or had failed and was kept past it.
                      filename:
                        type: string
                      byte_size:
                        type: integer
                        format: int64
                      part_size:
                        type: integer
                        description: Bytes per part (the last part carries the remainder).
                      parts_count:
                        type: integer
                      uploaded_parts:
                        type: array
                        items:
                          type: integer
                        description: Part numbers stored so far (while `open`).
                      expires_at:
                        type: string
                        format: date-time
                      result:
                        type: object
                        additionalProperties: true
                        description: Present once `completed` — what the door made
                          of the file.
                      error:
                        type: string
                        description: Present when `failed`, and kept on an `expired`
                          session that had failed.
        '403':
          description: App, token scope or record permission refused.
        '404':
          description: The tenant-scoped record is unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message_id
              - attachment_id
              - drive_id
              - request_id
              properties:
                message_id:
                  type: integer
                  minimum: 1
                attachment_id:
                  type: integer
                  minimum: 1
                drive_id:
                  type: integer
                  minimum: 1
                parent_id:
                  type: integer
                  nullable: true
                request_id:
                  type: string
                  format: uuid
  "/uploads/sessions":
    post:
      tags:
      - Upload Sessions
      security:
      - BearerAuth: []
      summary: Open an upload session
      description: |
        Declares the file and where it is going. The door authorizes the target
        and applies its size/type policy HERE, before any byte is sent
        (403 `forbidden`, 404 `not_found`, 422 `too_large` / `unsupported_type`).

        The response gives `part_size` and `parts_count`: split the file into
        consecutive parts of exactly `part_size` bytes (the last part carries
        the remainder) and PUT each one. Run up to 4 parts in parallel. An
        unfinished session expires 24 hours after it was opened, and one user
        may hold at most 20 open sessions in a business at a time
        (429 `too_many_uploads` — finish or DELETE one first).

        `context` by purpose. Unknown keys are dropped.

        * `drive` — `drive_id` (required), `parent_id`, `rel_path` (the file's
          webkitRelativePath, for an upload-folder drop: the folder chain is
          created under `parent_id`).
        * `ai_notepad` — `meeting_id` (required).
        * `recorder` — `recording_id` (required).
        * `drive_version` — `drive_id`, `item_id`, `base_version_id`, optional
          `change_summary` and `force`. Requires item editor and write:drive.
          A stale base returns 409 conflict before bytes; the base and permission
          are checked again at publication. `force: "true"` is an explicit user
          choice; upload-session context scalar values are strings.
          The completed result carries drive_item_id and version_id. A durable
          publication receipt makes a retry after interrupted completion safe.
        * `chat` — `room_id` (required), `media_type`, `width_px`, `height_px`.
        * `news_feed` — `feed_id`, `parent_type`, `parent_id`, `media_type`,
          `alt_text`, `width_px`, `height_px`.
        * `power_tables_import` — `table_id` (the table being imported into,
          when there is one; its owning app's admins may upload for it).
        * `hub_migration` — no context. A MangoApps Hub export bundle (a
          `.tar`, up to 20 GB) for the Hub → OneMango migration, sent by the
          business's own service account or one of its business administrators
          with `write:data_migration`. Receiving a bundle imports nothing; the
          import is a separate, previewed step. Available only where the
          migration is switched on (403 `not_available` elsewhere, including
          production for now); a token of another business is refused (403
          `forbidden`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - purpose
              - filename
              - byte_size
              properties:
                purpose:
                  type: string
                  enum:
                  - drive
                  - drive_version
                  - ai_notepad
                  - recorder
                  - chat
                  - news_feed
                  - power_tables_import
                  - hub_migration
                filename:
                  type: string
                  maxLength: 255
                content_type:
                  type: string
                  example: video/mp4
                byte_size:
                  type: integer
                  format: int64
                  description: Exact size of the file in bytes (max 20 GB, and the
                    door's own limit).
                context:
                  type: object
                  additionalProperties: true
      responses:
        '201':
          description: Session opened
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_session:
                    "$ref": "#/components/schemas/UploadSession"
        '403':
          description: 'The token lacks the door''s write scope (`insufficient_permissions`,
            "Required scope: …"), the caller may not upload to that target, or the
            door''s app is not enabled for the business / not available to this user
            (`app_not_enabled`, `app_not_accessible`, `forbidden`), or the door is
            not available in this environment (`not_available`).'
        '404':
          description: The target no longer exists.
        '422':
          description: 'Refused: `too_large`, `unsupported_type`, `unknown_purpose`,
            `invalid_upload`.'
        '429':
          description: "`too_many_uploads` — this user already holds the maximum number
            of open sessions. Finish or abort one."
  "/uploads/sessions/{token}":
    parameters:
    - name: token
      in: path
      required: true
      schema:
        type: string
    get:
      tags:
      - Upload Sessions
      security:
      - BearerAuth: []
      summary: Session status
      description: |
        While `status` is `open`, `uploaded_parts` lists the part numbers that
        have landed — after a dropped connection or an app restart, send only
        the ones that are missing. After `complete`, poll this until `status`
        is `completed` (read `result`) or `failed` (read `error`).

        `expired` is the third terminal state: an unfinished session that ran
        past `expires_at`, or a failed one kept past it. Its parts are gone —
        open a new session. A failed session that expires keeps its `error`.

        `result` by purpose:

        * `drive` — `{ drive_id, drive_item_id, parent_id, name }`. The file is
          already in Drive.
        * `ai_notepad` / `recorder` — `{ meeting_id` or `recording_id,
          signed_blob_id, complete_with }`. Post that `signed_blob_id` to the
          endpoint named in `complete_with` (the app's existing
          `…/audio/complete` or `…/media/complete`) within one hour to attach
          the file and start processing. Until you do, nothing owns the file.
        * `chat` / `news_feed` — `{ media }`, the same media object the door's
          own `media#create` returns; post it as you would any attachment.
        * `power_tables_import` — `{ blob_signed_id, filename }`. Carry the
          `blob_signed_id` into the import wizard's next step; it is scoped to
          this tenant's imports and the staged file is purged after 24 hours if
          the wizard is abandoned.
        * `hub_migration` — `{ bundle_id, filename, byte_size }`. The bundle is
          kept for the business; an administrator previews and runs its import
          separately.
      responses:
        '200':
          description: The session
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_session:
                    "$ref": "#/components/schemas/UploadSession"
        '404':
          description: No such session for this user.
    delete:
      tags:
      - Upload Sessions
      security:
      - BearerAuth: []
      summary: Abort a session
      description: |
        Discards a session that is still `open` (with every part already sent)
        or one that has `failed`. A session being assembled is left to finish,
        and a `completed` one is NOT deleted — for `ai_notepad` and `recorder`
        its file is unattached until you call the door's own `complete` leg, and
        the row is what the platform's cleanup reads to find that file. Both are
        a 204 no-op, and the session stays readable.
      responses:
        '204':
          description: Aborted, or a no-op for a session that is not `open`/`failed`.
        '404':
          description: No such session for this user.
  "/uploads/sessions/{token}/parts/{part_number}":
    parameters:
    - name: token
      in: path
      required: true
      schema:
        type: string
    - name: part_number
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
    put:
      tags:
      - Upload Sessions
      security:
      - BearerAuth: []
      summary: Send one part
      description: |
        The part's raw bytes as the request body (`Content-Type:
        application/octet-stream`); a multipart `chunk` field is accepted too.
        The body must be exactly the part's size — `part_size`, or the
        remainder for the last part (422 `part_size_mismatch` otherwise).
        Re-sending a part replaces it, so a failed or uncertain part is simply
        sent again. 503 `storage_unavailable` is retryable.
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Part stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  part_number:
                    type: integer
                  byte_size:
                    type: integer
                    format: int64
                  uploaded_parts:
                    type: array
                    items:
                      type: integer
        '409':
          description: "`session_closed` (no longer accepting parts) or `part_in_flight`."
        '410':
          description: "`session_expired` — open a new session."
        '422':
          description: "`part_out_of_range`, `part_missing` or `part_size_mismatch`."
        '503':
          description: "`storage_unavailable` — send the part again."
  "/uploads/sessions/{token}/complete":
    parameters:
    - name: token
      in: path
      required: true
      schema:
        type: string
    post:
      tags:
      - Upload Sessions
      security:
      - BearerAuth: []
      summary: Assemble the file
      description: |
        Call once every part has landed. The server re-checks the caller's
        access to the target, assembles the parts in the background, reads the
        file's real type and applies the door's policy to it, and hands the
        file to the door. Returns 202 immediately — poll the session for the
        outcome.

        SAFE TO CALL AGAIN, including concurrently: the session is claimed
        atomically, so only the first call assembles and a second is answered
        202 with the current state. A refusal the door makes while assembling
        (the file is not what it claimed, access was revoked) is reported on the
        session as `failed`, not on this response.
      responses:
        '202':
          description: Assembling (or already done)
          content:
            application/json:
              schema:
                type: object
                properties:
                  upload_session:
                    "$ref": "#/components/schemas/UploadSession"
        '403':
          description: The caller may no longer upload to that target — the door re-authorizes
            here.
        '404':
          description: No such session for this user, or the target no longer exists.
        '409':
          description: "`session_closed` — the session has already failed or expired;
            open a new one."
        '422':
          description: "`parts_missing` — `details.missing_parts` lists them."
  "/inspections/direct_uploads":
    post:
      deprecated: true
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: 'DEPRECATED: request a signed direct-upload URL for an inspection photo
        or video'
      description: |
        **Deprecated — post the file part on the write itself instead.** Every
        endpoint that took a `blob_signed_id` from here now accepts the bytes
        as multipart file parts in the SAME request, on every storage provider,
        with no blob minted before a record owns it and nothing to sweep:

        | Instead of a signed id on… | send |
        |---|---|
        | `POST /inspections/inspections/{id}/items/{id}/media` | `files[]` (or a single `file`) |
        | `POST /inspections/inspections` | `media_uploads[][file]` |
        | `POST /inspections/inspections/{id}/sync` | `media_uploads[][file]` |
        | `POST /inspections/sweeps/{id}/exceptions` | `photos[]` |
        | `PATCH /inspections/sweeps/{id}/exceptions/{id}` | `photos[]` |

        This endpoint keeps working for native clients already in the field.
        Every response carries `Deprecation: true`, a `Link` header naming the
        successor, and a `deprecation` object in the body.

        Wraps Active Storage's direct-upload endpoint with API-style auth
        and JSON envelope. The client SHA256-hashes its bytes, calls this
        endpoint to get back a presigned S3 URL plus the required headers,
        PUTs the raw bytes directly to S3, then passes the returned
        `signed_id` back to `POST inspections/:id/items/:id/media` (or the
        `inspections#create` / `inspections#sync` `media_uploads` array)
        to attach the blob.

        Limits: photos ≤ 20 MB, videos ≤ 50 MB. Allowed content types are
        listed in `DirectUploadBlobRequest.content_type`. To upload a
        document (PDF, Word, Excel, PowerPoint, plain text or CSV), set the
        top-level `non_media` flag to true — that adds those document types
        to the allow-list (the size cap still applies, ≤ 50 MB for non-photo
        types).

        The `signed_id` is accepted only where the bytes are virus-scanned
        before they attach: a photo or video at the Inspections doors above,
        and any allowed file at a form submission's or a broadcast's /
        alert's media fields (the Inspections doors refuse a document). No
        other endpoint that takes a signed blob id will take it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/DirectUploadBlobRequest"
      responses:
        '201':
          description: Signed upload URL issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  blob:
                    "$ref": "#/components/schemas/DirectUploadBlobResponse"
                  deprecation:
                    type: object
                    description: Names the multipart successor for this endpoint.
                    properties:
                      deprecated:
                        type: boolean
                        enum:
                        - true
                      successor:
                        type: string
                      message:
                        type: string
        '422':
          description: "`invalid_type` / `too_large` — content_type not allowed or
            file too large; `invalid_blob` — the blob metadata is missing a key or
            was rejected"
  "/inspections/inspections/awaiting_my_review":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Inspections in_review that the caller can approve
      description: |
        Returns inspections currently in `in_review` where the calling user
        is eligible to approve or reject at the current approval level.
        Backed by `ApprovalRequest.pending` filtered by
        `current_approval_level.can_approve?`. Capped at 50 rows — managers
        with deeper queues should also call the team list with
        `?status=in_review`.
      responses:
        '200':
          description: Pending review items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSummary"
        '401':
          description: Unauthorized
        '403':
          description: Forbidden — Inspections app disabled or no access
  "/inspections/inspections/{id}/bundle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: If-None-Match
      in: header
      required: false
      schema:
        type: string
      description: ETag from a prior bundle response; returns 304 if unchanged.
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline-fill bundle for a single in-flight inspection
      description: |
        Returns the inspection + its template (with all template items) +
        attached media references + the regulatory KB excerpts the form
        renderer needs to keep working after the network drops. Single
        round trip — the native client uses this on "Open inspection" so
        the inspector can continue offline. Honors `If-None-Match` for
        cheap revalidation.
      responses:
        '200':
          description: Bundle payload
          content:
            application/json:
              schema:
                type: object
                required:
                - inspection
                - template
                - etag
                - fetched_at
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  template:
                    "$ref": "#/components/schemas/InspectionTemplateDetail"
                  regulatory_kb:
                    description: 'Reserved. Currently ALWAYS an empty array, for every
                      tenant and every template — inspection templates carry no link
                      to the regulatory knowledge base, so there is nothing for the
                      server to resolve. The field is retained so the response shape
                      stays stable for existing clients; it can only become populated
                      once a template <-> knowledge base relationship exists. Do not
                      build a client feature that depends on it being non-empty. Deprecated:
                      it will be removed from this payload in the next API version.'
                    deprecated: true
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                          nullable: true
                        jurisdiction:
                          type: string
                          nullable: true
                        regulation:
                          type: string
                          nullable: true
                        summary:
                          type: string
                          nullable: true
                        updated_at:
                          type: string
                          format: date-time
                          nullable: true
                  etag:
                    type: string
                  fetched_at:
                    type: string
                    format: date-time
        '304':
          description: Not modified — matches client's If-None-Match
        '404':
          description: Inspection not found
  "/inspections/inspections/{id}/review/approve":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Approve an inspection at the current review level
      description: |
        Advances the in_review inspection's approval workflow by one level.
        The caller must satisfy `can_approve?` on the current
        `ApprovalLevel` (typically a manager or assigned reviewer).
        Once the final level approves, the inspection transitions to
        `completed`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
                  nullable: true
      responses:
        '200':
          description: Approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  approval_request:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                      current_level:
                        type: integer
                        nullable: true
        '422':
          description: Inspection is not in review / caller not eligible at this level
  "/inspections/inspections/{id}/review/reject":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reject an inspection at the current review level
      description: |
        Sends the inspection back to the inspector (transitions to
        `in_progress`) with the reviewer's required comment captured on
        the `ApprovalRequest`. Comment is required — empty rejects 422.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comments
              properties:
                comments:
                  type: string
                  minLength: 1
      responses:
        '200':
          description: Rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  approval_request:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
        '422':
          description: Comment missing / inspection not in review / caller not eligible
  "/inspections/inspections/{inspection_id}/items/{id}":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Update a single inspection item
      description: |
        Sets compliance status / response value / notes / failure severity
        on one inspection item. Only the assigned inspector (or a manager)
        can update. `compliance_status` and `failure_severity` are
        allowlisted on the server — invalid values 422 with a clear error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                compliance_status:
                  type: string
                  enum:
                  - pending
                  - passed
                  - failed
                  - na
                  - skipped
                response_value:
                  type: string
                  nullable: true
                notes:
                  type: string
                  nullable: true
                failure_severity:
                  type: string
                  enum:
                  - minor
                  - moderate
                  - severe
                  - critical
                  nullable: true
      responses:
        '200':
          description: Item updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  item:
                    "$ref": "#/components/schemas/InspectionItem"
                  inspection_progress:
                    type: number
                    format: float
                    nullable: true
                  inspection_server_version:
                    type: string
                    description: The inspection's `server_version` after this answer.
                      A score-changing answer re-stamps the inspection, so an offline
                      client replaces its stored inspection base with this value.
                  score_breakdown:
                    type: object
                    nullable: true
                    additionalProperties: true
                  failure_details_missing:
                    type: array
                    items:
                      type: string
                      enum:
                      - severity
                      - comment
                      - photo
                      - action_item
                    description: For an item graded failed on a template that requires
                      failure details, what it still lacks — so the app opens its
                      failure sheet as Fail does. Completing refuses (`failure_details_required`)
                      until they are sent. Empty when none.
                    example:
                    - severity
                    - comment
        '403':
          description: Forbidden — caller cannot modify this inspection
        '404':
          description: Inspection or item not found
        '422':
          description: Invalid status / validation failed
  "/inspections/inspections/{inspection_id}/items/{id}/media":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Attach photos or videos to an inspection item
      description: |
        Single canonical media-upload endpoint backed by `MediaItem`.

        1. **Multipart (use this)** — `files[]` for several files, or a single
           `file`, plus an optional `kind`. The bytes enter through Rails, are
           pushed to storage concurrently and are attached in the same request;
           nothing exists in storage that this item does not own by the end of
           it. Refusals are ALL-OR-NOTHING: one unsupported or oversize file
           fails the call and nothing is kept. The type is checked against the
           REAL bytes as well as the declared one, so an executable renamed
           `.png` is refused (422 `invalid_type`) and its bytes are purged in
           the same request.
        2. **Signed blob (deprecated)** — `{ blob_signed_id, kind }`, for
           native clients that already called
           `POST /inspections/direct_uploads`. Still works; the response
           carries `Deprecation: true`.

        `kind` is optional when `content_type` starts with `image/` or
        `video/`; explicit otherwise. Photos are capped at 20 MB, videos at
        50 MB, and at most 20 file parts per request.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                files:
                  type: array
                  maxItems: 20
                  items:
                    type: string
                    format: binary
                  description: The file parts. Use `file` for a single upload.
                file:
                  type: string
                  format: binary
                kind:
                  type: string
                  enum:
                  - photo
                  - video
                blob_signed_id:
                  type: string
                  deprecated: true
          application/json:
            schema:
              type: object
              properties:
                blob_signed_id:
                  type: string
                  deprecated: true
                kind:
                  type: string
                  enum:
                  - photo
                  - video
      responses:
        '201':
          description: Media attached
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_item:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionMediaItem"
                    description: The FIRST attached item, always present — a single-file
                      client reading this key is unaffected by the batch shape.
                  media_items:
                    type: array
                    description: Every item attached by this request, in submission
                      order.
                    items:
                      "$ref": "#/components/schemas/InspectionMediaItem"
                  media_count:
                    type: integer
                  already_attached:
                    type: integer
                    description: Present on a multipart call when one or more of the
                      posted files were already on this item (same bytes) — a retried
                      upload. Those files are echoed in `media_items` as the existing
                      rows and are not attached twice.
                  item:
                    "$ref": "#/components/schemas/InspectionItem"
        '403':
          description: Forbidden
        '422':
          description: "`invalid_type` / `too_large` / `no_file` / `invalid_file`
            — the file itself was refused; `upload_blocked` — the virus scan found
            malware (a `blob_signed_id` upload is deleted); `upload_incomplete` —
            a `blob_signed_id` upload does not match its declared checksum"
        '503':
          description: "`storage_unavailable` — file storage could not be reached;
            or `scanner_unavailable` — uploads are paused while virus scanning is
            unavailable. Nothing was kept; retry the call"
  "/inspections/inspections/{inspection_id}/items/{id}/media/{media_item_id}":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: media_item_id
      in: path
      required: true
      schema:
        type: integer
    delete:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Soft-delete a MediaItem attached to an inspection item
      description: |
        Soft-deletes the `MediaItem` (`deleted_at` set, file kept on S3).
        Mirrors `MediaItemsController#destroy`: fires the subject hook so
        a photo-required item that auto-passed via
        `on_media_item_attached` flips back to `pending` when the last
        photo is removed.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      responses:
        '200':
          description: Media removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_count:
                    type: integer
        '404':
          description: Media item not found
  "/inspections/inspections/{inspection_id}/items/{id}/annotation":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Save annotation data for a MediaItem
      description: |
        Persists a JSON annotation payload (drawing strokes, arrows,
        labels) onto the named MediaItem. Matches the canonical
        `MediaItemsController` annotation contract — sets
        `annotation_data` + `has_annotations: true`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - media_item_id
              - annotation_data
              properties:
                media_item_id:
                  type: integer
                annotation_data:
                  type: object
                  additionalProperties: true
      responses:
        '200':
          description: Annotation saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  media_item_id:
                    type: integer
                  annotation:
                    type: object
                    additionalProperties: true
        '404':
          description: Media item not found
        '422':
          description: media_item_id or annotation_data missing
  "/inspections/inspections/{inspection_id}/corrective_actions":
    parameters:
    - name: inspection_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List corrective actions on one inspection
      description: |
        The same list as `GET /inspections/corrective_actions` (same action,
        same filters, same item shape), narrowed to the corrective actions
        whose `inspection_id` is the path's `inspection_id`. Because of that
        narrowing, Visit Log plans of correction (parented by a visit
        finding) and actions raised by another app (no parent inspection)
        never appear here, and `meta.segment_counts` counts only this
        inspection's actions.

        The inspection itself is **not** looked up: an unknown inspection id,
        or one belonging to another business, answers **200 with an empty
        `items`** (never 404 — the list is always scoped to the caller's
        business, so nothing from another tenant is returned). A non-numeric
        id also yields an empty list (a value with leading digits, such as
        `12abc`, is read as that number). Read access to the inspection is not
        checked either; the caller sees only the rows the assignee scope
        below lets them see.

        Assignee scope, as on the top-level list:
        - default — actions on this inspection assigned to the caller, and
          only **open** ones (`pending` + `in_progress`) unless `status` or
          `overdue` is given;
        - `assigned_to_me=false` — actions on this inspection the caller is
          assigned to **or** created (any status);
        - `team=true` (manager, business admin or Inspections app admin) —
          every action on this inspection, but only when the inspection is
          inside the caller's team scope (`Inspections::TeamScopeService`);
          otherwise the list is empty. Non-managers who send `team=true` get
          the default personal scope.

        `for_inspection_id` is redundant on this route: it is applied as an
        additional `inspection_id` condition, so a value that differs from
        the path id yields an empty list.

        Ordering: open actions first, then `due_date` ascending, then
        `created_at` descending, then `id` descending.

        **Required Scopes:** `read:inspections` (GET and HEAD). A token
        without it is refused **403** `insufficient_permissions`
        ("Required scope: read:inspections"). Session-authenticated requests
        pass.
      parameters:
      - name: assigned_to_me
        in: query
        schema:
          type: boolean
          default: true
        description: |
          When false, also surfaces actions the caller created (and drops
          the open-only default). Ignored when `team=true` resolves to
          manager scope.
      - name: for_inspection_id
        in: query
        schema:
          type: integer
        description: |
          Redundant here — ANDed with the path `inspection_id`; a different
          value returns an empty list.
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          - open
          - closed
          - all
        description: |
          Raw enum (`pending`/`in_progress`/`completed`/`cancelled`) or a
          semantic segment: `open` (pending + in_progress — the Active tab),
          `closed` (completed + cancelled), or `all` (every status, opts out
          of the personal open-by-default). Any other value is refused 400
          `invalid_status`.
      - name: overdue
        in: query
        schema:
          type: boolean
        description: Only open actions past their due_date.
      - name: team
        in: query
        schema:
          type: boolean
          default: false
        description: |
          Manager-only. Widens the scope to every action on this inspection
          when the inspection is inside the caller's team scope.
          Non-managers who pass `team=true` silently get the personal scope.
      - "$ref": "#/components/parameters/Page"
      - name: per_page
        in: query
        schema:
          type: integer
          default: 20
          minimum: 1
          maximum: 100
        description: Items per page. Values outside 1–100 are clamped, not refused.
      responses:
        '200':
          description: Corrective actions on the inspection (empty when the inspection
            is unknown, another business's, or outside the caller's scope)
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  required:
                  - items
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/InspectionCorrectiveAction"
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/CollectionPaginationMeta"
                      - type: object
                        properties:
                          segment_counts:
                            type: object
                            description: |
                              Badge counts for the native My Actions chips,
                              computed from the same inspection + assignee
                              scope as `items` but ignoring the current
                              `status`/`overdue` filter. `active` and `open`
                              are the same number (pending + in_progress);
                              `closed` is completed + cancelled; `overdue`
                              is open actions past their due date.
                            properties:
                              all:
                                type: integer
                              active:
                                type: integer
                              open:
                                type: integer
                              pending:
                                type: integer
                              in_progress:
                                type: integer
                              completed:
                                type: integer
                              overdue:
                                type: integer
                              closed:
                                type: integer
                              cancelled:
                                type: integer
        '304':
          "$ref": "#/components/responses/NotModified"
        '400':
          description: "`invalid_status` — `status` is not one of the accepted values"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          description: Unauthorized — bearer token missing or invalid
        '403':
          description: |
            - `app_disabled` — Inspections is not enabled for the caller's business
            - `app_access_denied` — the caller does not meet the Inspections app's visibility rules
            - `insufficient_permissions` — the token lacks `read:inspections` ("Required scope: read:inspections")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: "`app_not_found` — the Inspections marketplace app is not configured
            on this install (never for an unknown inspection)"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Create a corrective action nested under an inspection
      description: |
        Creates an `InspectionCorrectiveAction` linked to the parent
        inspection. `assigned_to_id` and `inspection_item_id` are
        cross-tenant validated — a user outside the business, or an item
        from another inspection, are rejected 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                corrective_action:
                  type: object
                  required:
                  - description
                  properties:
                    inspection_item_id:
                      type: integer
                      nullable: true
                    description:
                      type: string
                    priority:
                      type: string
                      enum:
                      - low
                      - medium
                      - high
                      - critical
                      default: medium
                    due_date:
                      type: string
                      format: date
                      nullable: true
                    assigned_to_id:
                      type: integer
                      nullable: true
                    status:
                      type: string
                      enum:
                      - pending
                      - in_progress
                      - completed
                      - cancelled
                      default: pending
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  corrective_action:
                    "$ref": "#/components/schemas/InspectionCorrectiveAction"
        '404':
          description: Inspection not found
        '422':
          description: Invalid assignee / item / validation failed
  "/inspections/templates/{id}/bundle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    - name: If-None-Match
      in: header
      required: false
      schema:
        type: string
      description: ETag from a prior bundle response; returns 304 if unchanged.
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline pre-fetch bundle for an inspection template
      description: |
        Returns the full template + items + regulatory KB. The native
        client uses this so an inspector can pick a template, drop the
        network, fill an inspection locally, and `POST .../inspections`
        with the buffered answers on reconnect.
      responses:
        '200':
          description: Template bundle
          content:
            application/json:
              schema:
                type: object
                required:
                - template
                - etag
                - fetched_at
                properties:
                  template:
                    "$ref": "#/components/schemas/InspectionTemplateDetail"
                  regulatory_kb:
                    description: 'Reserved. Currently ALWAYS an empty array, for every
                      tenant and every template — inspection templates carry no link
                      to the regulatory knowledge base, so there is nothing for the
                      server to resolve. The field is retained so the response shape
                      stays stable for existing clients; it can only become populated
                      once a template <-> knowledge base relationship exists. Do not
                      build a client feature that depends on it being non-empty. Deprecated:
                      it will be removed from this payload in the next API version.'
                    deprecated: true
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        title:
                          type: string
                          nullable: true
                        jurisdiction:
                          type: string
                          nullable: true
                        regulation:
                          type: string
                          nullable: true
                        summary:
                          type: string
                          nullable: true
                        updated_at:
                          type: string
                          format: date-time
                          nullable: true
                  etag:
                    type: string
                  fetched_at:
                    type: string
                    format: date-time
        '304':
          description: Not modified — matches client's If-None-Match
        '404':
          description: Template not found
  "/inspections/schedules":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspection schedules
      description: |
        Read-only list of recurring inspection schedules. Schedule CRUD
        stays on the desktop admin surface — the native client only
        browses ("what's due at my location"). `?mine=true` restricts to
        schedules where the caller is the assigned inspector.
      parameters:
      - name: active
        in: query
        schema:
          type: boolean
        description: Filter on active/inactive. Defaults to active when omitted.
      - name: frequency
        in: query
        schema:
          type: string
      - name: location_id
        in: query
        schema:
          type: integer
      - name: template_id
        in: query
        schema:
          type: integer
      - name: mine
        in: query
        schema:
          type: boolean
          default: false
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Schedules
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/InspectionScheduleSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/inspections/schedules/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single schedule
      responses:
        '200':
          description: Schedule detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  schedule:
                    "$ref": "#/components/schemas/InspectionScheduleSummary"
        '404':
          description: Schedule not found
  "/inspections/schedules/due_today":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Schedules ready to generate now ("due today" feed)
      description: |
        Capped at 50 rows. Inspector-scoped by default; managers can
        request the team view with `?team=true`. Read-only — generation
        itself runs on the server via the scheduled job.
      parameters:
      - name: team
        in: query
        schema:
          type: boolean
          default: false
        description: Manager-only widen to team scope.
      responses:
        '200':
          description: Due-today schedules
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionScheduleSummary"
  "/inspections/claimable":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List items the caller can claim
      description: |
        Cross-cycle feed of every item the caller can claim right now — the
        mobile "My Inspections → Available to claim" surface. A claimable item is
        a pending `InspectionCycleItem` (no inspection yet) on an active
        `per_item` cycle whose `assignment_strategy = claim_pool`, at a location
        the caller is assigned to. Location-bounded for everyone (no manager
        bypass in the feed — a manager can still claim out-of-location items via
        the claim endpoint, but won't see them here). Optional `cycle_id`
        narrows to a single cycle.
      parameters:
      - name: cycle_id
        in: query
        schema:
          type: integer
        description: Narrow the feed to a single cycle.
      - name: location_id
        in: query
        schema:
          type: integer
        description: 'Narrow the feed to a single location. The feed is already bounded
          to the caller''s assigned locations, so this only further filters within
          that set — an unassigned location returns no items.

          '
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Claimable items
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/InspectionCycleItem"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/inspections/cycles":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List inspection cycles
      description: |
        Read-only paginated list of cycles. Cycle creation / activation /
        completion stays on the desktop admin surface.
      parameters:
      - name: status
        in: query
        schema:
          type: string
        description: When omitted, defaults to active cycles only.
      - name: template_id
        in: query
        schema:
          type: integer
      - "$ref": "#/components/parameters/Page"
      - "$ref": "#/components/parameters/PerPage"
      responses:
        '200':
          description: Cycles
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/InspectionCycleSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/inspections/cycles/active":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Active-cycle banner + the caller's coverage sweeps
      description: |
        The "My Inspections" home surface, mirroring the desktop
        `apps/inspections/inspections#my_inspections` page as two buckets:

        - `active_cycle` — the single most-recently-activated active,
          non-coverage_sweep cycle (the "Start {template}" banner). Not
          audience-filtered — matches the web banner. Carries `my_inspection`
          so the client shows Resume vs Start. Null when there is no active
          startable cycle.
        - `sweeps` — coverage sweeps the caller can work, via the same
          `InspectionSweep.for_inspector` rule the web uses (claim_pool → anyone
          at the location; zones → assigned / session inspectors).
      responses:
        '200':
          description: Active-cycle banner + sweeps
          content:
            application/json:
              schema:
                type: object
                properties:
                  active_cycle:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionCycleSummary"
                    nullable: true
                  sweeps:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSweepSummary"
  "/inspections/cycles/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Fetch a single cycle with progress
      responses:
        '200':
          description: Cycle detail with `progress` block
          content:
            application/json:
              schema:
                type: object
                properties:
                  cycle:
                    allOf:
                    - "$ref": "#/components/schemas/InspectionCycleSummary"
                    - type: object
                      properties:
                        progress:
                          type: object
                          properties:
                            total:
                              type: integer
                            by_status:
                              type: object
                              additionalProperties:
                                type: integer
                            items:
                              type: object
                              description: |
                                Per-item cycles only: the item register the caller may see.
                                `unstarted` items have no inspection yet (unclaimed, or nobody
                                could be assigned). `not_inspected` is the same count once the
                                cycle is closed (completed or archived) — they can no longer be
                                claimed or assigned — and 0 while it is open.
                              properties:
                                total:
                                  type: integer
                                completed:
                                  type: integer
                                unstarted:
                                  type: integer
                                not_inspected:
                                  type: integer
                                by_status:
                                  type: object
                                  additionalProperties:
                                    type: integer
                            sweeps:
                              type: object
                              description: |
                                Coverage-sweep cycles only: the sweeps the caller may see. The cycle
                                can be signed off once every sweep is. `ready_for_sign_off` counts
                                sweeps with every aisle covered that still wait on their sign-off.
                              properties:
                                total:
                                  type: integer
                                signed_off:
                                  type: integer
                                ready_for_sign_off:
                                  type: integer
        '404':
          description: Cycle not found
  "/inspections/cycles/{id}/start_inspection":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Start (or resume) an inspection for this cycle
      description: |
        Mobile equivalent of the desktop "Start <template>" cycle-banner button.
        Resumes the caller's existing in-progress/draft inspection for this
        cycle if present (no duplicate); otherwise creates one linked to the
        cycle with the inspector's primary work location pre-attached. If the
        cycle's template requires training the caller hasn't completed, the
        inspection is created as a draft. Returns `201` when a new inspection
        is created and `200` when an existing one is resumed.
      responses:
        '200':
          description: Existing inspection resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  resumed:
                    type: boolean
                    example: true
        '201':
          description: New inspection created
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
                  resumed:
                    type: boolean
                    example: false
        '422':
          description: |
            `cycle_inactive` — the cycle is no longer active; `no_template` —
            the cycle has no inspection template configured;
            `cycle_not_self_startable` — the cycle's inspections come from its
            items, claimed or assigned one at a time (claim an item with
            `POST /inspections/cycles/{cycle_id}/items/{id}/claim`); the
            message says why.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Cycle not found
  "/inspections/cycles/{cycle_id}/items/{id}/claim":
    parameters:
    - name: cycle_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Claim one unassigned cycle item
      description: |
        Claim one item from the pool. Race-safe — a guarded UPDATE flips exactly
        one of N simultaneous claimers; the winner gets a freshly-created
        inspection and can route straight into the editable form. Returns the
        same `InspectionDetail` shape `start_inspection` returns. Eligibility
        mirrors the web: an inspections manager/admin (claims anywhere) or a
        member assigned to the item's location. `409 already_claimed` is
        non-retryable — show "Already claimed", refresh the list, do not open
        the form.
      responses:
        '201':
          description: Item claimed — inspection created
          content:
            application/json:
              schema:
                type: object
                properties:
                  inspection:
                    "$ref": "#/components/schemas/InspectionDetail"
        '403':
          description: "`not_eligible` — caller is not assigned to the item's location"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: "`already_claimed` — another user won the race (non-retryable)"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: "`cycle_inactive` — the cycle is no longer active"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          description: Cycle or item not found
  "/inspections/sweeps":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Sweeps the caller can work
      description: The coverage sweeps this inspector can work (InspectionSweep.for_inspector)
        — same rule as the web "Coverage sweeps" section.
      responses:
        '200':
          description: Sweeps
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSweepSummary"
  "/inspections/sweeps/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Full sweep detail (aisles + grid + findings)
      responses:
        '200':
          description: Sweep detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  sweep:
                    "$ref": "#/components/schemas/InspectionSweepDetail"
        '403':
          description: Not authorized for this sweep
        '404':
          description: Sweep not found
  "/inspections/sweeps/{id}/bundle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline warm-up bundle
      description: Full detail + log-issue form schema + components. Supports If-None-Match
        → 304.
      responses:
        '200':
          description: Bundle
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/InspectionSweepBundle"
        '304':
          description: Not modified (ETag matched)
  "/inspections/sweeps/{id}/claim_aisle":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Claim the next unclaimed aisle (race-safe)
      description: Idempotency-Key supported — a retried claim returns the same aisle
        instead of grabbing a second. A handed-off aisle (in progress, nobody on it
        — the summary's `handed_off_aisles`) is taken before a fresh one, so the aisle
        returned may already be in progress, with cells. Pass `aisle_id` to pick up
        one specific handed-off aisle.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                  description: Pick up this handed-off aisle (optional).
      responses:
        '200':
          description: Aisle claimed
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
        '422':
          description: '`no_aisles` — nothing left to claim (the normal end-of-sweep
            outcome). With `aisle_id`: `aisle_taken` — another inspector is on that
            aisle; `aisle_done` — it was covered or skipped since it was listed; `aisle_not_found`
            — it is not on this sweep. `action_failed` — an unclassified server-side
            failure; treat it as retryable, NOT as "the sweep is finished".'
  "/inspections/sweeps/{id}/pause":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Pause / hand off
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                handoff_note:
                  type: string
                frontier_level:
                  type: integer
      responses:
        '200':
          description: Session ended
  "/inspections/sweeps/{id}/sync":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Offline batch flush
      description: |
        Buffered cells + findings + resets + resume cursor in one request. Idempotency-Key dedupes a replayed batch; a signed-off sweep rejects writes (409).

        A batch that carries `resets` erases recorded results as `reset_bay` /
        `reset_aisle` do, so it is delete-level (owner decision 2026-10-04,
        #6721): on top of `write:inspections` it requires the
        `destructive:inspections` scope.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                cells:
                  type: array
                  items:
                    type: object
                    properties:
                      aisle_id:
                        type: integer
                      bay:
                        type: integer
                      level:
                        type: integer
                exceptions:
                  type: array
                  items:
                    type: object
                  description: Each item mirrors the POST /exceptions body (component,
                    severity, bay, level, answers, and photos via photo_ids or blob_signed_ids).
                resets:
                  type: array
                  items:
                    type: object
                    properties:
                      aisle_id:
                        type: integer
                      bay:
                        type: integer
                frontier:
                  type: object
                  properties:
                    aisle_id:
                      type: integer
                    bay:
                      type: integer
      responses:
        '200':
          description: Applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  sweep:
                    "$ref": "#/components/schemas/InspectionSweepDetail"
                  applied:
                    type: object
                    properties:
                      cells:
                        type: integer
                      exceptions:
                        type: integer
                      resets:
                        type: integer
                  skipped:
                    type: array
                    description: Every submitted element that was NOT applied (empty
                      when all applied). `index` is the element's 0-based position
                      in the array it was submitted in (always 0 for frontier).
                    items:
                      type: object
                      properties:
                        kind:
                          type: string
                          enum:
                          - cell
                          - exception
                          - reset
                          - frontier
                        index:
                          type: integer
                        code:
                          type: string
                          description: invalid_element | aisle_not_found | out_of_range
                            | validation_failed | save_failed | action_failed
                        message:
                          type: string
                          description: Safe to show the inspector; never raw exception
                            text.
                  warnings:
                    type: array
                    description: Every submitted element that WAS applied (counted
                      in `applied`) but not whole — a finding saved with fewer photos
                      than it carried (empty when none). Do not re-send the finding;
                      attach the missing photos to `exception_id`. A finding whose
                      `aisle_id` names no aisle on this sweep is not applied and is
                      in `skipped` as `aisle_not_found`, like a cell or reset; a finding
                      sent with no `aisle_id` is saved without an aisle.
                    items:
                      type: object
                      properties:
                        kind:
                          type: string
                          enum:
                          - exception
                        index:
                          type: integer
                        code:
                          type: string
                          description: "`photos_not_attached`; or, beside it, `upload_blocked`
                            — the virus scan found malware in one of the finding's
                            `blob_signed_ids` photos, which is deleted and not attached
                            (do not re-send it) — or `upload_incomplete` — that upload
                            does not match its declared checksum (upload it again)"
                        message:
                          type: string
                          description: Safe to show the inspector.
                        exception_id:
                          type: integer
                          description: The saved finding.
                        photos_sent:
                          type: integer
                        photos_attached:
                          type: integer
        '409':
          description: "`sweep_locked` — sweep is signed off"
        '503':
          description: "`scanner_unavailable` — uploads are paused while virus scanning
            is unavailable, or `storage_unavailable` — a finding's `blob_signed_ids`
            photo could not be read. Nothing was written; retry the call"
  "/inspections/sweeps/{id}/sign_off":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Complete / sign off the sweep
      description: Routes for approval when the template requires it, else signs off.
        Open critical findings do not block (the sweep's `untracked_critical_count`
        is the warning to show). 422 only when the sweep is already pending review;
        409 once it is signed off.
      responses:
        '200':
          description: Signed off or sent for approval
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: signed_off | pending_approval
                  sweep:
                    "$ref": "#/components/schemas/InspectionSweepSummary"
        '409':
          description: "`sweep_locked` — the sweep is already signed off"
        '422':
          description: "`already_pending`"
  "/inspections/sweeps/{id}/review/approve":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Approve a pending review
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                comments:
                  type: string
      responses:
        '200':
          description: Approved (may sign off on final level)
        '422':
          description: "`cannot_review` — not awaiting review / not eligible"
  "/inspections/sweeps/{id}/review/reject":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reject a pending review (returns for rework)
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required:
              - comments
              properties:
                comments:
                  type: string
      responses:
        '200':
          description: Returned for rework
        '422':
          description: "`cannot_review` — missing reason / not eligible"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/mark_passed":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Mark a shelf (bay, level) passed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bay
              - level
              properties:
                bay:
                  type: integer
                level:
                  type: integer
      responses:
        '200':
          description: Aisle (with updated grid)
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
        '422':
          description: "`out_of_range`"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/reset_bay":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reset one bay to pending (keeps logged findings)
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bay
              properties:
                bay:
                  type: integer
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/reset_cell":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Un-mark one shelf (toggle a passed cell back to pending)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bay
              - level
              properties:
                bay:
                  type: integer
                level:
                  type: integer
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/reset_aisle":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reset the whole aisle (clears cells + soft-voids its open findings;
        resolved and corrective ones stay)
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/cover":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Bulk-cover the aisle (pass all non-exception shelves)
      responses:
        '200':
          description: Aisle covered
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
                  next_aisle_id:
                    type: integer
                    nullable: true
                    description: The aisle the inspector rolls onto — never one another
                      inspector is on.
                  done:
                    type: boolean
                    description: 'True only when no aisle is left uncovered. `next_aisle_id:
                      null` with `done: false` means the aisles left are being swept
                      by others.'
  "/inspections/sweeps/{sweep_id}/aisles/{id}/skip":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Skip an aisle (obstruction / unreachable)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                skip_reason:
                  type: string
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/unskip":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Restore a skipped aisle
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/aisles/{id}/advance_frontier":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Manager manually sets the frontier
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - frontier_bay
              properties:
                frontier_bay:
                  type: integer
      responses:
        '200':
          description: Aisle
          content:
            application/json:
              schema:
                type: object
                properties:
                  aisle:
                    "$ref": "#/components/schemas/InspectionSweepAisle"
  "/inspections/sweeps/{sweep_id}/exceptions":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: List findings
      parameters:
      - name: severity
        in: query
        schema:
          type: string
      - name: status
        in: query
        schema:
          type: string
      - name: aisle_id
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Findings
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSweepException"
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Log a finding
      description: 'Attach photos as `photos[]` multipart file parts (use this — one
        request, nothing pre-uploaded), as photo_ids (pending inbox MediaItem ids),
        or as blob_signed_ids (DEPRECATED: bytes PUT via /inspections/direct_uploads).
        All three are additive. Idempotency-Key dedupes a replayed POST.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                bay:
                  type: string
                level:
                  type: string
                kind:
                  type: string
                  description: "`noteworthy` logs a notable exception — position-less,
                    located by `location_text` (required then); omit for a rack finding."
                location_text:
                  type: string
                  description: Where a notable exception is ("Stairwell B, east end").
                    Required when `kind` is noteworthy.
                component:
                  type: string
                  description: Required on a rack finding. Optional on a notable exception
                    — none or blank is filed as "General" (the bundle's notable_form_schema.component_default).
                damage:
                  type: string
                severity:
                  type: string
                  description: 'case-insensitive: observation|minor|major|critical'
                note:
                  type: string
                answers:
                  type: object
                photo_ids:
                  type: array
                  items:
                    type: integer
                  description: Pending inbox MediaItem ids
                blob_signed_ids:
                  type: array
                  items:
                    type: string
                  deprecated: true
                  description: DEPRECATED — signed Active Storage blob ids from /inspections/direct_uploads
          multipart/form-data:
            schema:
              type: object
              properties:
                photos:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    format: binary
                  description: The finding's photo file parts, attached in THIS request
                    — no pre-uploaded blob and nothing to sweep. Every other field
                    above may be sent form-encoded alongside them. Refused files (unsupported
                    type, checked against the real bytes, or over the per-type cap)
                    fail the call with 422 (`invalid_type` / `too_large` / `no_file`
                    / `invalid_file` — the same vocabulary as the item media door)
                    and nothing is kept.
      responses:
        '503':
          description: "`storage_unavailable` — file storage could not be reached;
            or `scanner_unavailable` — uploads are paused while virus scanning is
            unavailable. Nothing was kept; retry the call"
        '201':
          description: Finding logged
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
                  corrective_action_created:
                    type: boolean
                  warnings:
                    type: array
                    description: The finding was saved but not whole — fewer photos
                      attached than were sent (`photos_not_attached`); empty when
                      none. Attach the missing photos to `exception_id` rather than
                      logging the finding again.
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                          description: photos_not_attached
                        message:
                          type: string
                          description: Safe to show the inspector.
                        exception_id:
                          type: integer
                        photos_sent:
                          type: integer
                        photos_attached:
                          type: integer
        '422':
          description: "`aisle_not_found` — `aisle_id` names no aisle on this sweep
            (the same refusal #sync gives as a skip); `upload_blocked` — the virus
            scan found malware in a `blob_signed_ids` photo (it is deleted); `upload_incomplete`
            — a `blob_signed_ids` upload does not match its declared checksum. Nothing
            was saved"
  "/inspections/sweeps/{sweep_id}/exceptions/{id}":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Finding detail
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
        '404':
          description: Not found
    patch:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Edit a logged finding
      description: Only provided fields change (omitted = unchanged; empty string
        clears). Status is preserved — use resolve/escalate to change it. Photos (`photos[]`
        multipart file parts, photo_ids, or the DEPRECATED blob_signed_ids) are additive.
        Moving bay/level/aisle repositions the red grid cell.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                aisle_id:
                  type: integer
                bay:
                  type: string
                level:
                  type: string
                component:
                  type: string
                damage:
                  type: string
                severity:
                  type: string
                  description: 'case-insensitive: observation|minor|major|critical'
                note:
                  type: string
                answers:
                  type: object
                photo_ids:
                  type: array
                  items:
                    type: integer
                  description: Pending inbox MediaItem ids (additive)
                blob_signed_ids:
                  type: array
                  items:
                    type: string
                  deprecated: true
                  description: DEPRECATED — signed Active Storage blob ids (additive)
          multipart/form-data:
            schema:
              type: object
              properties:
                photos:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    format: binary
                  description: The finding's photo file parts, attached in THIS request
                    — no pre-uploaded blob and nothing to sweep. Every other field
                    above may be sent form-encoded alongside them. Refused files (unsupported
                    type, checked against the real bytes, or over the per-type cap)
                    fail the call with 422 (`invalid_type` / `too_large` / `no_file`
                    / `invalid_file`) and nothing is kept. A photo already on the
                    finding (same bytes — a retried call) is skipped rather than attached
                    twice.
      responses:
        '200':
          description: Updated finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
                  warnings:
                    type: array
                    description: The edit was saved but not whole — fewer of the newly
                      sent photos attached than were sent (`photos_not_attached`);
                      empty when none. A photo already on the finding is not counted.
                      Attach the missing photos to `exception_id` again.
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                          description: photos_not_attached
                        message:
                          type: string
                          description: Safe to show the inspector.
                        exception_id:
                          type: integer
                        photos_sent:
                          type: integer
                        photos_attached:
                          type: integer
        '422':
          description: Update failed, a photo was refused (`upload_blocked` when the
            virus scan found malware in a `blob_signed_ids` photo, which is deleted;
            `upload_incomplete` when one does not match its declared checksum), or
            `aisle_not_found` — `aisle_id` names no aisle on this sweep (nothing was
            changed)
        '503':
          description: "`storage_unavailable` — file storage could not be reached;
            or `scanner_unavailable` — uploads are paused while virus scanning is
            unavailable. Nothing was kept; retry the call"
        '404':
          description: Not found
    delete:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Delete (soft-void) a logged finding
      description: |
        Soft-voids the finding — clears its red grid cell and hides it from the finding lists (index, sweep detail, notable_exceptions), while preserving the audited Inspection row and any corrective action. Not reversible via the API.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:inspections` scope.
      responses:
        '204':
          description: Deleted
        '404':
          description: Not found
        '422':
          description: Delete failed
  "/inspections/sweeps/{sweep_id}/exceptions/{id}/resolve":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Mark a finding resolved
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
  "/inspections/sweeps/{sweep_id}/exceptions/{id}/reopen":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Reopen a resolved finding
      description: The undo for resolve. Only a finding whose status is `resolved`
        goes back to open; any other answers 422 `not_resolved`.
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
        '422':
          description: The finding is not resolved
  "/inspections/sweeps/{sweep_id}/exceptions/{id}/escalate":
    parameters:
    - name: sweep_id
      in: path
      required: true
      schema:
        type: integer
    - name: id
      in: path
      required: true
      schema:
        type: integer
    post:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: Escalate a finding (opens a corrective action)
      responses:
        '200':
          description: Finding
          content:
            application/json:
              schema:
                type: object
                properties:
                  exception:
                    "$ref": "#/components/schemas/InspectionSweepException"
  "/inspections/bundles/sync_manifest":
    get:
      tags:
      - Inspections
      security:
      - BearerAuth: []
      summary: App-start warm-up bundle
      description: |
        One round-trip payload the native client fetches on app start
        (and after returning from offline). Pre-caches templates, the
        caller's open inspections, the caller's open corrective actions,
        and upcoming schedules — all sized so a single HTTP call covers
        a cold launch. Honors `If-None-Match` for cheap revalidation.
      parameters:
      - name: include
        in: query
        schema:
          type: string
        description: |
          Comma-separated subset of
          `templates,inspections,corrective_actions,schedules`. Defaults
          to all four.
      - name: days_ahead
        in: query
        schema:
          type: integer
          default: 7
          minimum: 1
          maximum: 60
        description: Schedules window in days from now.
      - name: If-None-Match
        in: header
        required: false
        schema:
          type: string
      responses:
        '200':
          description: Manifest payload
          content:
            application/json:
              schema:
                type: object
                required:
                - fetched_at
                - etag
                properties:
                  templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionTemplateSummary"
                  inspections:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionSummary"
                  corrective_actions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionCorrectiveAction"
                  schedules:
                    type: array
                    items:
                      "$ref": "#/components/schemas/InspectionScheduleSummary"
                  fetched_at:
                    type: string
                    format: date-time
                  etag:
                    type: string
        '304':
          description: Not modified — matches client's If-None-Match
  "/messaging/threads":
    get:
      tags:
      - Messaging
      summary: List conversations (chat list)
      description: |
        Paginated list of the caller's direct-message conversations, newest
        activity first. Each row carries an unread count and a compact
        last-message preview (with an attachments flag).

        By default this returns the caller's **live** conversations. Pass
        `archived=true` to return the caller's **archived** conversations
        instead (the ones hidden from the live list once the caller archives
        them; a new message resurfaces a thread back to the live list). Archive
        is per-caller, so the two slices differ per user on shared group threads.

        `meta.rules` carries the organization's Messages rules as they apply to
        the caller, so a client can show "New message", the group picker and the
        attach button only when they will work, and say how long messages are
        kept, instead of learning each rule from a refusal.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        description: Page number (1-indexed)
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: archived
        in: query
        required: false
        description: When true, returns the caller's archived conversations instead
          of the live list. Accepts true/false/1/0; defaults to false (live list).
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Paginated conversation list
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/MessagingThread"
                    meta:
                      allOf:
                      - "$ref": "#/components/schemas/MessagingPaginationMeta"
                      - type: object
                        properties:
                          rules:
                            "$ref": "#/components/schemas/MessagingRules"
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope
    post:
      tags:
      - Messaging
      summary: Start a conversation (and optionally post the first message)
      description: |
        Creates a 1:1 conversation (one recipient — idempotent, returns the
        existing thread if one exists) or a group conversation (2+ recipients).
        An optional `body` (+ `attachments[]`) posts the first message inline.
        Governance (`who_can_initiate`, group-DMs-enabled, participant cap) is
        enforced server-side.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: Recipient user ids (excluding the caller).
                subject:
                  type: string
                  description: 'Optional subject line, as the web and phone composers
                    ask for. It names only a conversation this call creates — a 1:1
                    that already exists keeps its own subject.

                    '
                title:
                  type: string
                  description: Optional group title.
                body:
                  type: string
                  description: Optional first message.
                attachments:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: Optional first-message file attachments.
                context_workspace_id:
                  type: integer
                  description: Optional origin workspace id.
      responses:
        '201':
          description: Conversation created (or existing 1:1 returned)
          content:
            application/json:
              schema:
                type: object
                properties:
                  thread:
                    "$ref": "#/components/schemas/MessagingThreadDetail"
        '401':
          description: Unauthorized
        '403':
          description: '`thread_not_created` — the caller may not start a new conversation
            (`error.message` names the organization''s rule, e.g. "You can''t start
            a new conversation. Managers and above can start a new conversation; everyone
            can reply."; replying to an existing conversation is never blocked by
            it — see `meta.rules.can_start` on GET /messaging/threads) or group messages
            are turned off; or `information_barrier` — the company''s information
            barriers keep the caller and a recipient apart (the existing one-to-one
            is not reopened either)'
        '503':
          description: "`information_barrier_unavailable` — who is in an information
            barrier's group couldn't be read just now; try again"
        '422':
          description: No recipients (`no_recipients`), a recipient unavailable (`recipients_unavailable`)
            or participant cap exceeded. A recipient who is no longer active is named
            in `error.message` and listed in `error.details.departed_user_ids`.
  "/messaging/threads/unread_count":
    get:
      tags:
      - Messaging
      summary: Total unread DM badge
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Unread total
          content:
            application/json:
              schema:
                type: object
                properties:
                  unread:
                    type: integer
        '401':
          description: Unauthorized
  "/messaging/recipients":
    get:
      tags:
      - Messaging
      summary: Recipient picker (searchable org users)
      description: |
        Searchable, recent-DM-boosted list of users the caller can message —
        the same ranking the web "New Message" picker uses. Scoped to the
        caller's business, and leaves out the people the company's information
        barriers keep the caller from (a conversation with them is refused).
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: Search term (name / email / employee id / phone / title).
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      responses:
        '200':
          description: Ranked user results
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingRecipient"
                  pagination:
                    type: object
                    properties:
                      more:
                        type: boolean
        '401':
          description: Unauthorized
  "/messaging/reaction_options":
    get:
      tags:
      - Messaging
      summary: Reaction emoji a user may add to a message
      description: |
        The static allowlist of emoji a caller can add as a reaction on a Direct
        Message, each with a display label — the same set ReactionsController
        validates against and the web thread reaction bar renders. Identical for
        every message (no thread/message context); fetch once to build a reaction
        picker instead of hardcoding the set.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The applicable reactions
          content:
            application/json:
              schema:
                type: object
                properties:
                  reactions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingReactionOption"
        '401':
          description: Unauthorized
        '403':
          description: Messages disabled for the organization
  "/messaging/threads/{id}":
    get:
      tags:
      - Messaging
      summary: Open a conversation (marks it read)
      description: Returns the conversation with its participants and marks it read
        for the caller.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Conversation detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  thread:
                    "$ref": "#/components/schemas/MessagingThreadDetail"
        '401':
          description: Unauthorized
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/read":
    patch:
      tags:
      - Messaging
      summary: Mark a conversation read
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Marked read
        '404':
          description: Conversation not found
  "/messaging/threads/{id}/unread":
    post:
      tags:
      - Messaging
      summary: Mark a conversation unread
      description: |
        The inverse of PATCH `/messaging/threads/{id}/read`, and the same write as
        the web Messages "Mark unread from here": the CALLER's read marker is moved
        back to just before `message_id`, so that message and every later message
        someone else wrote count as unread. Without `message_id` it moves back
        before the newest message someone else wrote (the caller's own messages
        never count as unread). The conversation then shows its `unread_count` in
        GET `/messaging/threads` and the badge total; opening it (GET
        `/messaging/threads/{id}` with a write-capable token) or PATCH `/read`
        clears it again. Per-caller; nobody else's read state changes.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                message_id:
                  type: integer
                  description: A message in this conversation to mark unread from.
                    Omit to mark unread from the newest message someone else wrote.
      responses:
        '200':
          description: Marked unread
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  unread_count:
                    type: integer
                    example: 3
                    description: The conversation's unread messages for the caller
                      after the change.
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant), or `message_id`
            is not a message in it
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    enum:
                    - thread_not_found
                    - message_not_found
        '422':
          description: No `message_id` was given and nobody else has written in the
            conversation
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    example: nothing_to_mark_unread
  "/messaging/threads/{id}/archive":
    post:
      tags:
      - Messaging
      summary: Archive a conversation (per-user)
      description: |
        Archives the conversation for the CALLER only — stamps `archived_at` on
        the caller's own participant row so it drops out of their live list
        (`GET /messaging/threads`) and appears under `?archived=true`. Per-user
        and never touches another participant's row or the thread itself. An
        archived conversation resurfaces automatically the next time anyone posts
        to it; use `POST /messaging/threads/{id}/unarchive` to bring it back
        manually. Same semantics as the web Messages "Archive" action.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/unarchive":
    post:
      tags:
      - Messaging
      summary: Unarchive a conversation (per-user)
      description: |
        Clears the caller's archive (`archived_at` → null) so the conversation
        returns to their live list (`GET /messaging/threads`). The explicit
        inverse of `POST /messaging/threads/{id}/archive` — archived conversations
        also resurface implicitly when anyone posts a new message, but this lets a
        caller pull one back without waiting. Per-user (never touches another
        participant's row) and idempotent — unarchiving a conversation that isn't
        archived still returns 200.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Unarchived
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/rename":
    patch:
      tags:
      - Messaging
      summary: Rename a group conversation
      description: |
        Sets a custom title on a GROUP conversation (1:1 conversations cannot be
        renamed → 422). The title is trimmed and capped at 120 characters; a
        blank title clears the custom name and the conversation falls back to its
        participant-derived label. Same semantics as the web Messages
        "Rename conversation" action. Returns the detailed thread payload.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  maxLength: 120
                  description: New conversation name. Blank clears the custom name.
                  example: Launch War Room
      responses:
        '200':
          description: Renamed — detailed thread payload
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
        '422':
          description: Not renameable (1:1 conversation) or title too long
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: thread_not_renamed
  "/messaging/threads/{id}/leave":
    post:
      tags:
      - Messaging
      summary: Leave a group conversation
      description: |
        Removes the CALLER's own participant row from a GROUP conversation. The
        thread itself is untouched — remaining participants keep the full history
        and the leaver simply stops seeing (and being notified about) the
        conversation. 1:1 conversations cannot be left → 422; use archive to hide
        a 1:1 instead. Same semantics as the web Messages "Leave
        conversation" action. Removes only the caller — there is no endpoint to
        remove another member.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:messages` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Left the conversation
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
        '422':
          description: Not leaveable (1:1 conversation)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: thread_not_left
  "/messaging/threads/{id}/mute":
    post:
      tags:
      - Messaging
      summary: Mute a conversation (per-user)
      description: |
        Mutes the conversation for the CALLER only — writes `muted_until` on the
        caller's own participant row (≈ 100 years out). While muted the message
        notifier skips the caller and the conversation drops out of the unread
        badge total; per-row unread counts are unaffected. Same semantics as the
        web Messages 3-dot "Mute conversation" action. Idempotent.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Muted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  muted:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/unmute":
    post:
      tags:
      - Messaging
      summary: Unmute a conversation (per-user)
      description: |
        Clears the caller's mute (`muted_until` → null) so notifications and
        badge counts resume. Same semantics as the web "Unmute" action.
        Idempotent — unmuting an already-unmuted conversation still returns 200.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Unmuted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    example: true
                  muted:
                    type: boolean
                    example: false
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled or insufficient scope (needs write:messages)
        '404':
          description: Conversation not found (or caller not a participant)
  "/messaging/threads/{id}/add_participants":
    post:
      tags:
      - Messaging
      summary: Add members to a group conversation
      description: |
        Adds one or more people to a GROUP conversation (1:1 conversations
        cannot take new members → 422). Resolution is STRICT: if any requested
        `user_id` doesn't resolve inside the caller's business the whole call
        fails and nobody is added — never a partial add. Users already in the
        conversation are skipped gracefully (re-adding is a no-op, still 200).
        The group participant cap is enforced across existing + new members.
        Same semantics as the web Messages "Add people" action. Returns the
        refreshed participant roster.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_ids
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: IDs of the users to add. Must all resolve inside the
                    caller's business.
                  example:
                  - 42
                  - 57
      responses:
        '200':
          description: Members added — refreshed participant roster
          content:
            application/json:
              schema:
                type: object
                properties:
                  participants:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingParticipant"
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled, insufficient scope (needs write:messages),
            group messages turned off for the organization, or the caller may not
            start conversations (who_can_initiate) — `error.message` says which; or
            `information_barrier` — the company's information barriers keep someone
            being added apart from someone already in it, or from another person being
            added (nobody is added)
        '503':
          description: "`information_barrier_unavailable` — who is in an information
            barrier's group couldn't be read just now; try again"
        '404':
          description: Conversation not found (or caller not a participant)
        '422':
          description: Not a group conversation, no/invalid user_ids, or participant
            cap exceeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: participants_not_added
  "/messaging/threads/{id}/participants":
    get:
      tags:
      - Messaging
      summary: List the conversation's users
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Participants
          content:
            application/json:
              schema:
                type: object
                properties:
                  participants:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MessagingParticipant"
        '404':
          description: Conversation not found
  "/messaging/threads/{thread_id}/messages":
    get:
      tags:
      - Messaging
      summary: List messages in a conversation
      description: |
        Message history, newest first.

        Two modes. Without `before_id` this is page/offset based and the
        response carries `meta`. Offset paging is not stable over a live
        conversation — a message posted between two fetches shifts the window
        and the next page repeats rows, one deleted between fetches skips a row
        — so pass `before_id` to walk the history with a cursor instead: the
        `per_page` messages strictly older than that message id. The cursor
        response carries `cursor` instead of `meta`; feed `cursor.next_before_id`
        back as `before_id` and stop when `cursor.has_more` is false.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        description: Ignored when `before_id` is given.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 30
          minimum: 1
          maximum: 100
      - name: before_id
        in: query
        description: Message id cursor. Returns the `per_page` messages older than
          this id, newest first. Optional; omit for page-based paging.
        schema:
          type: integer
          minimum: 1
      - name: around_id
        in: query
        description: 'Open the conversation AT this message (a mention, a search hit),
          as the web and phone pages do: a window of `per_page` messages, newest first
          — half at or before it (it included), half after it. Wins over `before_id`.
          The `cursor` then carries `around_id`, `next_before_id` (send it as `before_id`
          to page older), `has_more_before` and `has_more_after`; for "Back to latest",
          request with no cursor. 404 `message_not_found` for a message not in this
          conversation.'
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Paginated messages
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/MessagingMessage"
                    meta:
                      "$ref": "#/components/schemas/MessagingPaginationMeta"
                    cursor:
                      type: object
                      description: Present only when `before_id` or `around_id` was
                        given (replaces `meta`).
                      properties:
                        before_id:
                          type: integer
                        around_id:
                          type: integer
                          description: Only with around_id — the message the window
                            is centred on.
                        next_before_id:
                          type: integer
                          nullable: true
                          description: Oldest id in this window; pass back as `before_id`.
                            Null when the window is empty.
                        has_more:
                          type: boolean
                          description: With before_id — older messages remain.
                        has_more_before:
                          type: boolean
                          description: With around_id — older messages remain.
                        has_more_after:
                          type: boolean
                          description: With around_id — newer messages remain ("Back
                            to latest").
        '304':
          "$ref": "#/components/responses/NotModified"
        '401':
          description: Unauthorized
        '404':
          description: Conversation not found
          or (with around_id) message_not_found:
    post:
      tags:
      - Messaging
      summary: Post a message (text + attachments)
      description: |
        multipart/form-data — `body` text and/or repeated `attachments[]` file
        parts. When file uploads are disabled for the business, a post carrying
        attachments is refused whole with 422 ("File attachments are disabled
        for your organization") — the body is not posted without them.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                body:
                  type: string
                attachments:
                  type: array
                  items:
                    type: string
                    format: binary
      responses:
        '201':
          description: Message posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '401':
          description: Unauthorized
        '404':
          description: Conversation not found
        '403':
          description: "`information_barrier` — a one-to-one conversation the company's
            information barriers keep apart takes no new messages"
        '422':
          description: Empty message, not a participant, or attachments sent while
            file uploads are disabled
  "/messaging/threads/{thread_id}/messages/{id}":
    patch:
      tags:
      - Messaging
      summary: Edit a message (author only)
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
      responses:
        '200':
          description: Message updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '403':
          description: "`information_barrier` — a one-to-one conversation the company's
            information barriers keep apart takes no edits (deleting your own message
            still works)"
        '404':
          description: Message not found or not the caller's
        '422':
          description: Blank body
    delete:
      tags:
      - Messaging
      summary: Delete a message (author only, soft delete)
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:messages` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Message soft-deleted (returns the tombstoned message)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '404':
          description: Message not found or not the caller's
  "/messaging/threads/{thread_id}/messages/{id}/forward":
    post:
      tags:
      - Messaging
      summary: Forward a message into another conversation
      description: |
        Forwards the message at `{id}` (in a conversation the caller belongs to)
        to one or more recipients — parity with the web "Forward" link. The
        destination receives the quoted original ("On <date>, <author> wrote:
        > …") and, when the source thread has a subject, "Fwd: <subject>".

        Recipients are chosen by the caller (`user_ids[]`, or the legacy single
        `user_id`): 1 recipient reuses/opens the idempotent 1:1 conversation, 2+
        open a group. `body` is an OPTIONAL note added above the quote. The
        ORIGINAL ATTACHMENTS ARE NOT CARRIED — identical to the web forward.

        Governed exactly like a hand-typed message (who_can_initiate / group-DMs
        toggles, @mentions against the destination's members, broadcast and
        notifications). Honors `Idempotency-Key` — a retry replays the first
        response instead of forwarding twice.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_ids
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: Recipient user ids. 1 => 1:1, 2+ => group.
                user_id:
                  type: integer
                  description: Legacy single recipient (merged with user_ids).
                body:
                  type: string
                  description: Optional note added above the quoted original.
      responses:
        '201':
          description: Message forwarded
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
                  thread_id:
                    type: integer
                    description: The destination conversation (new or reopened).
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Present on a partial failure, and when the original
                      carried files or voice notes — they are not forwarded and a
                      warning names them (omitted on a clean forward of text only).
        '401':
          description: Unauthorized
        '403':
          description: "`message_not_forwarded` — the caller may not start a new conversation
            (`error.message` names the organization's rule; see `meta.rules.can_start`
            on GET /messaging/threads) or group messages are turned off; or `information_barrier`
            — the company's information barriers keep the caller and a recipient apart"
        '503':
          description: "`information_barrier_unavailable` — who is in an information
            barrier's group couldn't be read just now; try again"
        '404':
          description: Source message or conversation not found
        '422':
          description: No recipients / recipients unavailable — a recipient who is
            no longer active is named in `error.message` and listed in `error.details.departed_user_ids`
  "/messaging/threads/{thread_id}/messages/{message_id}/attachments/{id}":
    delete:
      tags:
      - Messaging
      summary: Remove one attachment (author only)
      description: |
        DMs are private — admins/managers do NOT bypass the author check.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:messages` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Attachment removed. When it was all the message held (no text,
            no other file, no voice note) the message itself is deleted instead —
            to the author's Trash, with the file — and `message_deleted` is true.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  message_deleted:
                    type: boolean
                    description: True when removing this file deleted its now-empty
                      message.
        '403':
          description: Not the caller's message
        '404':
          description: Attachment or conversation not found
  "/messaging/threads/{thread_id}/messages/{message_id}/reactions":
    post:
      tags:
      - Messaging
      summary: React to a message (add, idempotent)
      description: |
        Adds the caller's `emoji` reaction to a message in a conversation they
        belong to (you may react to anyone's message, not only your own). Split
        from the web toggle into an explicit add so the outcome does not depend
        on current state. Idempotent — re-adding an emoji the caller already left
        returns 200 with the message unchanged instead of duplicating it. The
        response carries the full message with its updated `reactions` block, so
        no follow-up read is needed. `emoji` must be one of the company's reaction
        palette (`GET /messaging/reaction_options` lists it).
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: One emoji from the company's reaction palette.
      responses:
        '200':
          description: Reaction already existed (idempotent repeat)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '201':
          description: Reaction added
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled / missing write:messages, or `information_barrier`
            — a new reaction in a one-to-one conversation the company's information
            barriers keep apart (removing your own still works)
        '404':
          description: Conversation or message not found
        '422':
          description: Unsupported emoji
  "/messaging/threads/{thread_id}/messages/{message_id}/reactions/{id}":
    delete:
      tags:
      - Messaging
      summary: Remove the caller's reaction
      description: |
        Removes the caller's `emoji` reaction. The emoji is passed as the `id`
        path segment (or as an `emoji` query parameter). Idempotent — removing a
        reaction that is not there returns 200 with the message unchanged, so a
        retry does not 404. Only the caller's own reaction is ever affected. The
        response carries the full message with its updated `reactions` block.
      security:
      - BearerAuth: []
      parameters:
      - name: thread_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: The emoji to remove (URL-encoded).
      - name: emoji
        in: query
        required: false
        schema:
          type: string
        description: Alternative to the path segment.
      responses:
        '200':
          description: Reaction removed (or already absent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/MessagingMessage"
        '401':
          description: Unauthorized
        '403':
          description: Messages app disabled / missing write:messages
        '404':
          description: Conversation or message not found
        '422':
          description: Unsupported emoji
  "/chat/rooms":
    get:
      tags:
      - Chat
      summary: List the caller's chat rooms
      description: |
        Paginated list of the caller's active rooms (direct / group / channel),
        with unread counts, pin/mute state, a compact last-message preview, and
        per-room capability flags.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        description: Filter by room type. Omit for all rooms.
        schema:
          type: string
          enum:
          - direct
          - group
          - channel
      - name: q
        in: query
        description: |
          Keep the rooms whose title holds every word typed (a DM's title is
          the other person's name), or whose active members' names do —
          member names only in rooms whose member list the caller may see,
          never the caller's own name; accents ignored. Combines with `scope`
          and pages like the full list. Message content is not searched here
          (that is `GET /chat/search/conversations`).
        schema:
          type: string
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Paginated room list
          content:
            application/json:
              schema:
                type: object
                properties:
                  rooms:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatRoom"
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/ChatPaginationMeta"
                    - type: object
                      properties:
                        scope:
                          type: string
                          nullable: true
                          enum:
                          - direct
                          - group
                          - channel
                          description: |
                            The room-type filter ACTUALLY applied, normalized
                            (case and surrounding whitespace are forgiven), or
                            null when none was. An unrecognized `scope` fails
                            open to the full list rather than erroring - read
                            this key rather than assuming the request's own
                            value was honoured.
                        q:
                          type: string
                          nullable: true
                          description: The `q` filter applied (trimmed), or null when
                            none was.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or insufficient scope
    post:
      tags:
      - Chat
      summary: Create a room (DM, group or channel)
      description: |
        Creates a direct message (`room_type: direct` + `other_user_id`), a
        group (`room_type: group` + `member_user_ids`) or a channel
        (`room_type: channel` + `name`). A DM always answers 200, whether it
        was made now or already existed (the pair has one DM); 201 is for groups
        and channels only. Gated by the
        `direct_messages_enabled` / `group_chats_enabled` business settings
        (channels ride the group-chats switch).

        **Channels** (Chat Wave 1) are made by the same service as the web's
        Create Channel drawer: only people the "Who can create channels"
        setting allows (403 `channel_not_allowed`; `GET /chat/config` →
        `can_create_channels` says in advance); the name is normalised to
        lowercase words joined by dashes ("Store 412 Team" → `store-412-team`)
        and must be unique among the company's channels (422 `name_invalid`).

        **Groups** (decision D5) hold up to 20 people, the caller included
        (422 `group_too_large` — `GET /chat/config` → `group_member_limit`), and
        have no name of their own: they are named after their members. A
        `name` sent with `room_type: group` makes a PRIVATE channel instead when
        the caller may create channels (`converted_to_channel: true`);
        otherwise it is not used and `notice` says so.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - room_type
              properties:
                room_type:
                  type: string
                  enum:
                  - direct
                  - group
                  - channel
                other_user_id:
                  type: integer
                  description: DM peer (direct rooms only).
                name:
                  type: string
                  description: A channel's name (required for a channel; normalised,
                    at most 80 characters). For a group, see the description — a group
                    has no name of its own.
                topic:
                  type: string
                  maxLength: 500
                  description: Channel only — what it is for, shown in Browse Channels.
                avatar:
                  allOf:
                  - "$ref": "#/components/schemas/ChatChannelAvatarChoice"
                  description: Channel only — the picture someone chose (as `avatar[emoji]`
                    / `avatar[color]`). Left out, the channel gets the one `GET /chat/channels/avatar_suggestion`
                    previews.
                is_public:
                  type: boolean
                  default: true
                  description: Channel only — listed in Browse Channels and open to
                    join (false = private). A JSON boolean or "true"/"false".
                posting:
                  type: string
                  enum:
                  - B
                  - G
                  default: B
                  description: Channel only — who may post (B any member, G channel
                    admins only — an announcement channel; members may still reply
                    in threads).
                member_user_ids:
                  type: array
                  items:
                    type: integer
                  description: Initial members (group/channel; excludes the caller).
                    A group needs at least one.
                recipient_group_id:
                  type: integer
                  description: 'Channel only, Chat admins only (403 `channel_not_allowed`
                    otherwise) — the channel''s members are everyone in this group,
                    kept in step hourly (`member_user_ids` is then not used). Only
                    a group `GET /chat/channels/groups` lists may be followed: groups
                    whose people change on their own (on shift now or working today,
                    near a place, recent and new hires, an attribute match on a dimension
                    the clock moves — tenure, location now, clocked in) and groups
                    that reach nobody until a send names a place or a department (Location
                    Managers, Location Staff) are refused (422 `group_not_found`),
                    and so is a group holding nobody active besides the caller (422
                    `group_empty`). The response''s `members_sync` is `synced` (added
                    now), `queued` (a big group — added within minutes) or `failed`
                    (the group could not be read; the hourly run adds them).'
                photo:
                  type: string
                  format: binary
                  description: Optional group/channel photo.
                photo_signed_id:
                  type: string
                  description: Channel only — an imported list's photo (`photo.signed_id`
                    from `POST /chat/member_imports`).
                other_member_invite:
                  type: string
                  enum:
                  - "Y"
                  - "N"
                  description: Channel only — the room permission codes of ChatRoomPermissionCodes
                    (an imported list's reviewed settings). An `im_permission` sent
                    wins over `posting`; `S` from someone who is not a business admin
                    is 403 `permission_tier_not_allowed`.
                can_view_member:
                  type: string
                  enum:
                  - B
                  - G
                  description: Channel only — see other_member_invite.
                im_permission:
                  type: string
                  enum:
                  - B
                  - G
                  - S
                  description: Channel only — see other_member_invite.
                important_msg_sender:
                  type: string
                  enum:
                  - B
                  - G
                  - S
                  description: Channel only — see other_member_invite.
                thread_replies:
                  type: string
                  enum:
                  - B
                  - G
                  description: Channel only — see other_member_invite.
      responses:
        '200':
          description: The direct room — made now or already there (a DM always answers
            200)
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '201':
          description: Group or channel created
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  converted_to_channel:
                    type: boolean
                    description: room_type=group only — true when the typed name made
                      a private channel (D5).
                  notice:
                    type: string
                    nullable: true
                    description: room_type=group only — set when a typed name was
                      not used ("Groups have no name of their own, so this one shows
                      its members' names.").
                  photo_error:
                    type: string
                    description: room_type=channel only — present when `photo_signed_id`
                      was sent but the photo couldn't be added (the channel stands);
                      the sentence to show.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled, DMs/group chats disabled, `channel_not_allowed`
            (may not create channels), `permission_tier_not_allowed` (`S` from someone
            who is not a business admin), or `information_barrier` (Chat Wave 4 —
            the company's information barriers keep these people apart; `error.message`
            is the sentence to show) / 503 `information_barrier_unavailable` (who
            is in a barrier group couldn't be read just now — try again)
        '422':
          description: Peer is self / users not in this business / `members_required`
            / `name_blank` / `name_invalid` (a channel already has it) / `topic_too_long`
            / `group_too_large` / `invalid_permission_setting` / `chat_not_open` (Chat
            isn't open to someone picked) / `group_not_found` (`recipient_group_id`
            is not a group a channel may follow — see `GET /chat/channels/groups`)
            / `group_empty` (the group holds nobody active besides the caller, so
            the channel would hold only them)
  "/chat/rooms/{id}":
    get:
      tags:
      - Chat
      summary: Room detail + message timeline
      description: |
        Returns the room plus a message page ordered newest-first, paginated by
        message id (`before_id` cursor). The initial page (`before_id=0`) also
        marks the room read for the caller. The caller's read position (and
        the Seen by others see) moves only for a session or a token holding
        write:chat; a read-only token reads without marking. `mark_read=false`
        reads a room the client is not showing (a re-read after `room:updated`,
        a preview) without marking it.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: mark_read
        in: query
        description: false — return the room and its newest page without marking it
          read (the read position, unread count and Seen by do not move). Omitted
          or true — the initial page marks it read, as before.
        schema:
          type: boolean
          default: true
      - name: before_id
        in: query
        description: Return messages strictly older than this id (0 = newest page).
        schema:
          type: integer
          default: 0
      - name: around_id
        in: query
        description: |
          Jump-to-message window - roughly half the page at or before this id
          and half after it, in chronological order. Takes precedence over
          `before_id`, and does not mark the room read.
        schema:
          type: integer
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      - name: include_thread_replies
        in: query
        description: Include thread replies inline in the timeline.
        schema:
          type: boolean
          default: false
      - "$ref": "#/components/parameters/ChatThreadsParam"
      responses:
        '200':
          description: Room + message page
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  has_more:
                    type: boolean
                  oldest_message_id:
                    type: integer
                    nullable: true
                  target_removed:
                    type: boolean
                    description: True when `around_id` names a message that was removed
                      from the room (deleted or taken down by a moderator) — the window
                      shows its neighbours and the client should say the message was
                      removed. False otherwise.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    patch:
      tags:
      - Chat
      summary: Update a group/channel
      description: |
        Rename a channel, set its topic, and/or change the photo or settings of
        a group/channel. Direct rooms cannot be edited. At least one change is
        required.

        Who may: a group's admins; a channel's admins and the company's Chat
        admins (`can_edit_details` on the room) — never while the channel is
        archived (403 `channel_archived`). A channel's name is normalised and
        unique in the company (422 `name_invalid`); a topic change posts a
        timeline notice.

        A group has no name of its own (D5): a NEW `name` for a group answers
        409 `conversion_required` with `conversion_endpoint`
        (`POST /chat/rooms/{id}/convert_to_channel`) and changes nothing. The
        group's current name sent back unchanged (an Edit-Group modal saving
        its photo or settings) is ignored and the rest is saved.

        The room's settings ride the same request: any of the five permission
        codes, with the rules of `PATCH /chat/rooms/{id}/permissions` (keys
        left out are unchanged; setting or clearing `S` needs a business admin
        — 403 `permission_tier_not_allowed`), and the Ask AI
        proactive-assistance opt-in (`ask_ai_proactive_enabled`,
        `ask_ai_proactive_detectors` — needs the Chat setting
        `ask_ai_enabled`, 403 `ask_ai_disabled`). Every value is checked before
        anything is written, so a refused one leaves no rename applied.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              allOf:
              - type: object
                properties:
                  name:
                    type: string
                    description: A channel's new name.
                  topic:
                    type: string
                    maxLength: 500
                    description: A channel's topic; blank clears it.
                  avatar:
                    allOf:
                    - "$ref": "#/components/schemas/ChatChannelAvatarChoice"
                    nullable: true
                    description: A channel's picture (`avatar[emoji]` / `avatar[color]`);
                      blank picks one from the name again. Refused with 422 `invalid_avatar`
                      before anything is written.
                  photo:
                    type: string
                    format: binary
                    description: PNG/JPEG/GIF/WebP, max 10 MB.
                  remove_photo:
                    type: boolean
                    description: Clear the existing photo.
                  ask_ai_proactive_enabled:
                    type: boolean
                    description: Opt this room in to Ask AI's unprompted suggestions
                      (off by default).
                  ask_ai_proactive_detectors:
                    type: object
                    description: Per-detector switches; detectors left out are unchanged.
                      Any other key, or a value that is not an object, is 422 `invalid_proactive_detectors`.
                    properties:
                      unanswered_question:
                        type: boolean
                      meeting_intent:
                        type: boolean
                      commitment:
                        type: boolean
              - "$ref": "#/components/schemas/ChatRoomPermissionCodes"
      responses:
        '200':
          description: Room updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '403':
          description: Not a member or not a room admin / `channel_archived` / `ask_ai_disabled`
            (the Ask AI keys sent while the Chat setting `ask_ai_enabled` is off)
            / `permission_tier_not_allowed` (only a business admin may set or clear
            `S`)
        '404':
          description: Room not found
        '409':
          description: "`conversion_required` — a new name for a group; make it a
            private channel instead."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  conversion_endpoint:
                    type: string
                    example: "/api/v1/chat/rooms/42/convert_to_channel"
        '422':
          description: "`direct_room` / `nothing_to_update` / `name_blank` / `name_invalid`
            / `topic_too_long` / `photo_type` / `photo_too_large` / `photo_not_attached`
            / `invalid_permission_setting` (a code that setting does not take) / `invalid_proactive_detectors`"
    delete:
      tags:
      - Chat
      summary: Leave a room (or delete it for everyone with for_all=true)
      description: |
        Default is a per-user soft leave (no-op for DMs). `for_all=true`
        deletes the room for everyone — room creator or chat app admin
        only. It is a soft delete: the room and its history can be restored
        for 30 days from Chat › Recently deleted.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:chat` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: for_all
        in: query
        description: Delete the room for all members (creator/admin only; restorable
          for 30 days).
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Left or deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  archived:
                    type: boolean
                  left:
                    type: boolean
                  deleted:
                    type: boolean
        '403':
          description: Not a member
          or not creator/admin for for_all:
        '404':
          description: Room not found
        '422':
          description: "`error.code` says which refusal: `direct_room` — a direct
            room cannot be deleted for everyone; `legal_hold` — the room is on legal
            hold (said to a business admin, with where to lift it); `not_deletable`
            — the same refusal to anyone else, whose message names no hold (the room's
            `delete_restriction` carries both ahead of time); `not_destroyed` — the
            delete for everyone did not go through, try again; `creator_cannot_leave`
            — the room's creator cannot leave it; `last_admin` — the caller is the
            room's only admin still active in the business, so another member must
            be made admin first. A leave repeated after the first one ran is not an
            error: it answers 200 with `left: false`."
  "/chat/rooms/mark_all_read":
    post:
      tags:
      - Chat
      summary: Mark every conversation read (conversation-list "Mark all as read")
      description: |-
        Bulk twin of `PATCH /chat/rooms/{id}/mark_read`, backing the conversation list's "⋯ → Mark all as read". Sweeps every room the caller's list shows — not one page and not one filter — advancing each read cursor and clearing each unread count in a single statement. Idempotent: a second call with nothing unread returns `marked_count: 0`.

        `marked_count` / `room_ids` report the conversations that were actually SHOWING as unread, so a client can quote the number and clear exactly those badges. Rooms whose cursor merely caught up (their newest message is the caller's own) are swept too but are not counted.

        `awaiting_ack_room_ids` names the rooms that still carry a pending Important or Read-Receipt acknowledgement. Marking read deliberately does NOT acknowledge on the user's behalf, so those rooms keep their `has_important_messages` / `has_read_receipt_messages` flag on the next list fetch — clients should explain that rather than treat it as the action failing.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Sweep applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  marked_count:
                    type: integer
                    description: Conversations that were unread and now are not
                  room_ids:
                    type: array
                    items:
                      type: integer
                    description: Ids of those conversations
                  awaiting_ack_room_ids:
                    type: array
                    items:
                      type: integer
                    description: Rooms still holding an unacknowledged Important /
                      Read-Receipt message
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
          or token lacks write:chat:
  "/chat/rooms/{id}/mark_read":
    patch:
      tags:
      - Chat
      summary: Mark all messages in a room read (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Read cursor advanced (noop=true when nothing was unread)
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  noop:
                    type: boolean
                  last_read_message_id:
                    type: integer
                    nullable: true
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/mentionable_users":
    get:
      tags:
      - Chat
      summary: Mention typeahead within a room
      description: Search the room's members by name (or email, where the People directory
        shows the caller email) for @-mention typeahead. Members on the other side
        of one of the company's information barriers from the caller are left out
        (Chat Wave 4).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: q
        in: query
        description: Search query (min 2 chars to filter).
        schema:
          type: string
      responses:
        '200':
          description: Matching members
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/ChatUserSummary"
                      - type: object
                        properties:
                          detail:
                            type: string
                            description: 'What tells this member apart from a colleague
                              with the same name: their job title, then (only for
                              namesakes) their location, department or Employee ID,
                              as far as the People directory shows them to the caller.'
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
        '404':
          description: Room not found
  "/chat/rooms/{id}/files":
    get:
      tags:
      - Chat
      summary: Paginated attachment list for a room
      description: All file/media attachments shared in the room, newest first.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 30
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: Paginated file list
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        message_id:
                          type: integer
                        filename:
                          type: string
                        content_type:
                          type: string
                        byte_size:
                          type: integer
                        url:
                          type: string
                        preview_url:
                          type: string
                          nullable: true
                        uploaded_by:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/ChatUserSummary"
                        created_at:
                          type: string
                          format: date-time
                  meta:
                    allOf:
                    - "$ref": "#/components/schemas/ChatPaginationMeta"
                    - type: object
                      properties:
                        has_more:
                          type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/pin":
    post:
      tags:
      - Chat
      summary: Pin a room for the caller (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Room pinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  pinned_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    delete:
      tags:
      - Chat
      summary: Unpin a room for the caller (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Room unpinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  pinned_at:
                    type: string
                    nullable: true
                    description: Always null after unpin.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/mute":
    post:
      tags:
      - Chat
      summary: Mute a room for the caller
      description: |
        Mute until a specific time (`muted_until` in epoch milliseconds) or
        indefinitely (0 / null / omitted). A deadline already passed is
        refused (422 `invalid_muted_until` — epoch seconds sent as milliseconds
        land in 1970 and are refused the same way); one further ahead than 100
        years is held to 100 years, which is also what "indefinitely" stores.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                muted_until:
                  type: integer
                  nullable: true
                  description: Epoch milliseconds; 0/null = mute indefinitely.
      responses:
        '200':
          description: Room muted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  muted_until:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
        '422':
          description: "`invalid_muted_until` — that deadline has already passed"
    delete:
      tags:
      - Chat
      summary: Unmute a room for the caller (idempotent)
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Room unmuted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  muted_until:
                    type: string
                    nullable: true
                    description: Always null after unmute.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/important_messages":
    get:
      tags:
      - Chat
      summary: Unread important messages in a room
      description: |
        Important messages (`ack_type=important`) the caller has not yet
        acknowledged — powers the client's "unread important" modal.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      responses:
        '200':
          description: Unacknowledged important messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
  "/chat/rooms/{room_id}/members":
    get:
      tags:
      - Chat
      summary: List room members
      description: |
        Active members, admins first, then by join date, then by id. Paginated:
        the default page size (200) is larger than any roster we have seen, so
        an existing caller that ignores `page` is not truncated today — but a
        roster above the page size IS truncated, and `meta` is the only thing
        that says so. This block was undocumented until 2026-09-02 while the
        endpoint was already paginating, which is why it is spelled out here.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: per_page
        in: query
        schema:
          type: integer
          default: 200
          minimum: 1
          maximum: 500
      responses:
        '200':
          description: Paginated member list
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMember"
                  meta:
                    "$ref": "#/components/schemas/ChatPaginationMeta"
                  read_positions_hidden:
                    type: object
                    description: |
                      Present only when read positions were withheld on this page:
                      by a works council (a group or channel; never a 1:1
                      conversation), or — Chat Wave 4 — for every other member while
                      the company's "Show who has seen a message" switch is off
                      (reason "Your company doesn't show who has seen a message.").
                      Those members' `last_read_message_id` is null, which means
                      "not shown", not "unread".
                    properties:
                      count:
                        type: integer
                      reason:
                        type: string
                        example: Names are hidden for people in Germany (works council).
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    post:
      tags:
      - Chat
      summary: Add members to a group/channel (room admin only)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_ids
              properties:
                user_ids:
                  type: array
                  items:
                    type: integer
                  description: Users to add (member_user_ids also accepted).
      responses:
        '200':
          description: Members added (partitioned by outcome)
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  added:
                    type: array
                    items:
                      type: integer
                  rejoined:
                    type: array
                    items:
                      type: integer
                  already_member:
                    type: array
                    items:
                      type: integer
        '401':
          description: Unauthorized
        '403':
          description: Not a member or not a room admin, or `information_barrier`
            (Chat Wave 4 — the company's information barriers keep these people apart;
            `error.message` is the sentence to show) / 503 `information_barrier_unavailable`
            (who is in a barrier group couldn't be read just now — try again) — someone
            being added is across a barrier from someone in the room or being added
            with them
        '404':
          description: Room not found
        '409':
          description: "`group_full` — a group holds up to `limit` people (D5). Make
            it a private channel first: POST `conversion_endpoint` with the same `user_ids`,
            which adds them once it is a channel. Nobody was added."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  limit:
                    type: integer
                    example: 20
                  user_ids:
                    type: array
                    items:
                      type: integer
                  conversion_endpoint:
                    type: string
        '422':
          description: Direct room / empty user_ids / users not in business / `chat_not_open`
            (Chat isn't open to someone picked — `error.message` names them) / `archived`
            (an archived channel takes nobody; restore it first)
  "/chat/rooms/{room_id}/members/{id}":
    patch:
      tags:
      - Chat
      summary: Change a member's role (room admin only)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Membership id (not user id).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - role
              properties:
                role:
                  type: string
                  enum:
                  - admin
                  - member
      responses:
        '200':
          description: Role updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/ChatMember"
        '403':
          description: Not a member or not a room admin
        '404':
          description: Room or member not found
        '422':
          description: |
            `invalid_role`; `last_admin` (no other admin still active in this
            organization would remain); `member_inactive` (promoting someone no
            longer active here); `direct_room` (a 1:1 conversation has no admins).
    delete:
      tags:
      - Chat
      summary: Remove a member (self, or any member as room admin)
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:chat` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: Membership id (not user id).
      responses:
        '200':
          description: Member removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  membership_id:
                    type: integer
                  removed_user_id:
                    type: integer
                  self_left:
                    type: boolean
        '403':
          description: Not authorized to remove this member
        '404':
          description: Room or member not found
        '422':
          description: Direct room / creator leaving / last admin
  "/chat/rooms/{room_id}/media":
    post:
      tags:
      - Chat
      summary: Upload a chat attachment
      description: |
        Two legs, and the request decides which.

        **Multipart (preferred).** Send the call as `multipart/form-data` with
        a `file` part. The bytes go through the API, the content type is
        determined from the BYTES (the declared `Content-Type` is not trusted),
        and the upload is finalized in this same request: the response carries
        the `media` payload with no `upload_url`, and there is nothing further
        to call. Reference the media id in `media_ids` when posting a message.
        `media_type` is optional here — it is derived from the file when
        omitted, and checked against the real bytes when supplied.

        **Legacy presign (deprecated).** A JSON body with no file still returns
        a presigned PUT URL (15-minute TTL) for native clients already in the
        field: PUT the bytes, then call `/complete`. It is only available where
        the deployment's storage supports presigned PUTs; elsewhere it answers
        422 `direct_upload_unsupported`, which means "send the file as a
        multipart `file` part instead".

        A file larger than 200 MB belongs on neither leg — open a chunked
        upload session (`POST /uploads/sessions` with `purpose: "chat"` and
        `context: { room_id, media_type }`), whose result is the same `media`
        payload.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The attachment itself. Its real content type is sniffed
                    from the bytes.
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                  description: Optional — derived from the file when omitted.
                original_filename:
                  type: string
                  description: Optional — the part's own filename is used when omitted.
                width_px:
                  type: integer
                  description: Images/GIFs only.
                height_px:
                  type: integer
                  description: Images/GIFs only.
          application/json:
            schema:
              type: object
              required:
              - media_type
              - mime_type
              - original_filename
              - file_size_bytes
              description: Legacy presign leg — deprecated, see the description.
              properties:
                media_type:
                  type: string
                  enum:
                  - image
                  - gif
                  - video
                  - file
                mime_type:
                  type: string
                  example: image/jpeg
                original_filename:
                  type: string
                file_size_bytes:
                  type: integer
                width_px:
                  type: integer
                  description: Images/video only.
                height_px:
                  type: integer
                  description: Images/video only.
      responses:
        '201':
          description: |
            Multipart: the attachment is stored and finalized (`media` only).
            Legacy presign: the row exists and the bytes are still to be PUT.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    "$ref": "#/components/schemas/ChatMedia"
                  upload_url:
                    type: string
                    description: Legacy presign leg only — absent on the multipart
                      leg.
                  storage_key:
                    type: string
                    description: Legacy presign leg only.
                  expires_in:
                    type: integer
                    description: Legacy presign leg only — seconds until upload_url
                      expires.
        '403':
          description: Not a room member
          not allowed to send in this room:
          or file uploads disabled:
        '404':
          description: Room not found
        '422':
          description: |
            The file was refused: a type chat does not share (`renderable_mime`,
            `renderable_extension` — HTML, SVG, XML; `blocked_mime`,
            `blocked_extension` — executables; `unsupported_extension`), content
            that contradicts the extension (`extension_content_mismatch`,
            `mime_type_mismatch`), too large (`file_too_large`), too many files
            (`attachment_cap_reached`), or `direct_upload_unsupported`.
            For every code above except `direct_upload_unsupported` (which
            tells the client to resend as multipart), `error.message` is
            written for the person who picked the file, in their display
            language — show it as the reason instead of a generic "upload
            failed". A renderable or executable EXTENSION is judged before the
            content, so the message for one equals that extension's entry in
            GET /chat/config `attachment_rules.refused_extensions`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        example: renderable_extension
                      message:
                        type: string
                        example: HTML files can't be shared in chat because they can
                          run code when opened. Zip the file or share a PDF instead.
  "/chat/rooms/{room_id}/media/{id}/complete":
    patch:
      tags:
      - Chat
      summary: Finalize a legacy presigned upload (uploader only)
      description: |
        LEGACY leg only — call after the presigned PUT succeeds. The multipart
        leg has already done this inside the create call and must not call
        here. Images/files become `ready` immediately; videos stay `pending`
        until transcoding completes. The stored object is measured here, so
        this can answer 422 (`file_too_large`, `upload_missing`).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Upload finalized
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    "$ref": "#/components/schemas/ChatMedia"
        '403':
          description: Not a room member
        '404':
          description: Room or media not found (or not the caller's upload)
  "/chat/rooms/{room_id}/media/{id}":
    delete:
      tags:
      - Chat
      summary: Discard an in-flight upload (uploader only)
      description: Only unattached uploads can be removed — once a message references
        the media it is permanent.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Upload discarded
        '403':
          description: Not a room member
        '404':
          description: Room or media not found (or not the caller's upload)
        '422':
          description: Media already attached to a sent message
  "/chat/rooms/{room_id}/messages":
    get:
      tags:
      - Chat
      summary: Room message history (paged)
      description: |
        The history pager. Shares one window contract with
        `GET /chat/rooms/{id}` so both surfaces stay in lockstep. Omit every
        cursor for the newest page; pass `before_id` to walk strictly older
        history; pass `around_id` to land on a message of any age in one
        request (it wins over `before_id`). Opening the newest page marks the
        room read - paging with a cursor deliberately does not, so walking
        history never bumps `last_read` forward. The caller's read position
        (and the Seen by others see) moves only for a session or a token
        holding write:chat; a read-only token reads without marking, and so
        does `mark_read=false`, as on `GET /chat/rooms/{id}`.

        `has_more` is derived by over-fetching one row, so a room holding
        exactly `limit` messages correctly reports `has_more: false` rather
        than offering a page that comes back empty.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: mark_read
        in: query
        description: false — read the newest page without marking the room read. Omitted
          or true — it marks it read, as before.
        schema:
          type: boolean
          default: true
      - name: before_id
        in: query
        description: Return messages strictly older than this id (exclusive).
        schema:
          type: integer
      - name: around_id
        in: query
        description: |
          Jump-to-message window - roughly half the page at or before this id
          and half after it, in chronological order. Takes precedence over
          `before_id`.
        schema:
          type: integer
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          minimum: 1
          maximum: 100
      - name: include_thread_replies
        in: query
        description: |
          Surface inline quote-replies in the timeline too. Default false
          keeps the top-level-only contract thread-based clients expect.
        schema:
          type: boolean
          default: false
      - "$ref": "#/components/parameters/ChatThreadsParam"
      responses:
        '200':
          description: Message window
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  has_more:
                    type: boolean
                  oldest_message_id:
                    type: integer
                    nullable: true
                  target_removed:
                    type: boolean
                    description: True when `around_id` names a message that was removed
                      from the room (deleted or taken down by a moderator) — the window
                      shows its neighbours and the client should say the message was
                      removed. False otherwise.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
    post:
      tags:
      - Chat
      summary: Post a message (asynchronous)
      description: |
        Queues the message for creation and Pusher broadcast (202). Provide
        `client_uuid` to reconcile the optimistic client bubble with the
        broadcast. Attach pre-uploaded media via `media_ids` (max 10).
        `ack_type` marks the message as an Important Message or Read Receipt
        Request (feature-gated; allowed in DMs, groups and private channels
        of up to 50 active members — a public or bigger channel answers 403
        `ack_not_allowed_in_channel` (decision D12); important messages
        cannot carry attachments).

        Channel threads (D1): a client that draws threads sends a reply in a
        channel with `in_thread: true` — it is allowed by the room's
        `can_reply_in_thread` (D8: in a channel only admins post in, members
        may still reply in threads unless a channel admin turned that off) and
        stays in the thread unless `also_send_to_channel` is true AND the
        caller may post in the channel. A channel reply WITHOUT `in_thread`
        (an older client) posts to the channel as an inline quote, as before.
        A reply to a reply joins its root's thread.

        Every switch on this API (`in_thread`, `also_send_to_channel`, and
        `is_public`, `push_to_talk`, `allow_multiple`, `threads`, `mark_read`
        elsewhere) takes a JSON boolean or the strings "true"/"false" ("1"/"0").
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  description: Message text — required unless media_ids is present.
                parent_message_id:
                  type: integer
                  description: Message to quote/reply to (one-level inline quote;
                    in a channel, its thread).
                in_thread:
                  type: boolean
                  default: false
                  description: Channel only — this reply belongs to the parent's thread
                    (a client that draws threads).
                also_send_to_channel:
                  type: boolean
                  default: false
                  description: With in_thread — also show the reply in the channel
                    timeline (only for someone who may post there).
                client_uuid:
                  type: string
                  description: Client-generated UUID for dedup / optimistic UI.
                media_ids:
                  type: array
                  maxItems: 10
                  items:
                    type: integer
                  description: Completed, unattached upload ids from POST /media.
                ack_type:
                  type: string
                  enum:
                  - important
                  - read_receipt
                  description: Request explicit acknowledgement (feature-gated).
                escalate_to_user_id:
                  type: integer
                  description: 'With `ack_type: important` (a DM, a group or a private
                    channel of up to 50 people): the backup to tell if someone named
                    on the read report has not acknowledged after `escalate_after_minutes`.
                    Any active person Chat is open for other than the sender (they
                    need not be in the room). They get a notice naming who has not
                    acknowledged — the words only if they are in the room; a backup
                    outside the room also gets a "Message <name>" link to start a
                    chat with them (decision D14). The sender''s own messages then
                    carry `escalation`.'
                escalate_after_minutes:
                  type: integer
                  enum:
                  - 60
                  - 120
                  - 240
                  - 1440
                  description: How long to wait before telling the backup. Both or
                    neither.
      responses:
        '202':
          description: Message queued for delivery
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: queued
                  client_uuid:
                    type: string
                    nullable: true
                  room_id:
                    type: integer
                  thread_only:
                    type: boolean
                    description: The reply was kept in its thread
                    not the channel timeline.:
        '403':
          description: Not a room member / `im_not_allowed` (posting or thread replies
            not allowed here, or the channel is archived) / `ack_not_allowed_in_channel`
            (an ack_type in a public channel or one above 50 people — D12) / `important_not_allowed`
            (Important messages are off for the company, or this room's tier does
            not include the caller) / `read_receipts_not_allowed` (read-receipt requests
            are off for the company) / `read_receipts_not_allowed_here` (this room's
            tier does not include the caller) — the room's `ack_restriction` / `read_receipt_restriction`
            give the same code and sentence before a send / `information_barrier`
            (Chat Wave 4 — a direct message the company's information barriers keep
            apart is read-only; the room's `send_restriction` says so before a send)
        '422':
          description: 'Blank body+media / invalid media_ids / disallowed ack_type
            / bad parent / broadcast_not_allowed (an @channel, @here or group mention
            from someone Chat settings leave out — Chat Wave 2; ask POST …/mention_audience
            first) / a refused backup (Chat Wave 3: escalation_incomplete, escalation_needs_important,
            escalation_not_here, invalid_escalation_wait, invalid_backup, backup_without_chat
            — `error.message` says why)'
  "/chat/rooms/{room_id}/messages/{id}":
    get:
      tags:
      - Chat
      summary: One message
      description: |
        Release 3 · one message of the room, exactly as the history draws it —
        `{ message }`, the same wrapper `PATCH` answers with — so a client can
        redraw one bubble (after a `message:updated`, a push or a link it
        opened) without paging the timeline. Any message the caller may read
        here: a reply kept in its channel thread too, with `escalation` on the
        caller's own Important message, and a channel's root message carries its
        `thread` line (as in `GET …/messages?threads=true`).

        The history's own rules: an active member of the room, Chat open to
        them, and a session or a token with `read:chat` (`read:own_chat` also
        satisfies, as for the history). Reading it never marks anything read.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The message
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a member of the room; `app_not_enabled`
            / `app_not_accessible` — Chat is not open to the caller; `insufficient_permissions`
            — the token holds neither `read:chat` nor `read:own_chat`; `work_context_restricted`
            — the company's Chat access policy (see `error_detail`), as for the history"
        '404':
          description: "`room_not_found`; or `message_not_found` — deleted, not there,
            or in another room"
    patch:
      tags:
      - Chat
      summary: Edit a message (author only, 15-minute window)
      description: |
        Edits the message body and broadcasts the update. Gated by the
        `allow_edit_chat_messages` business setting; attachment-only messages
        are not editable, and neither is a forward (a message with
        `forwarded_from`): 403 `forwarded_message`, whose `error.message` is
        the sentence to show ("Forwarded messages can't be edited. Delete it
        and forward it again."). Deleting a forward works as for any message.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
      responses:
        '200':
          description: Message updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not author / editing disabled / outside edit window (`not_editable`)
            / a forward (`forwarded_message`)
        '404':
          description: Message not found
        '422':
          description: Blank body
    delete:
      tags:
      - Chat
      summary: Delete a message (author or room admin, soft delete)
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:chat` scope.

        The room hears `message:deleted` (see `POST /chat/pusher/auth`); a
        deleted thread reply's event carries its root's thread line.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Message soft-deleted (returns the tombstoned message)
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not the author and not a room admin
        '404':
          description: Message not found
  "/chat/rooms/{room_id}/messages/{message_id}/reactions":
    post:
      tags:
      - Chat
      summary: React to a message (idempotent)
      description: Re-posting the same emoji returns 200 instead of duplicating.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - emoji
              properties:
                emoji:
                  type: string
                  description: The quick set (GET /chat/reactions) first, but any
                    single emoji is accepted (Chat Wave 2); anything else is 422 unsupported_emoji.
      responses:
        '200':
          description: Reaction already existed (idempotent repeat)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reaction:
                    "$ref": "#/components/schemas/ChatReaction"
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '201':
          description: Reaction added
          content:
            application/json:
              schema:
                type: object
                properties:
                  reaction:
                    "$ref": "#/components/schemas/ChatReaction"
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not a room member
        '404':
          description: Room or message not found
        '422':
          description: Unsupported emoji
  "/chat/rooms/{room_id}/messages/{message_id}/reactions/{id}":
    delete:
      tags:
      - Chat
      summary: Remove the caller's reaction
      description: |
        `id` accepts either the numeric reaction id or the emoji string itself
        (only the caller's own reaction is matched by emoji).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Reaction id or emoji string.
      responses:
        '200':
          description: Reaction removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not a room member or not the reaction owner
        '404':
          description: Room
          message:
          or reaction not found:
  "/chat/rooms/{room_id}/messages/{message_id}/thread":
    get:
      tags:
      - Chat
      summary: Thread view (parent + replies)
      description: |
        The newest `limit` replies, oldest first; `before_id` pages older ones.
        In a channel (D1) a reply's id opens its root's thread, `following`
        says whether the caller follows it (D10), and opening it marks its
        replies read for them (the Threads view's unread count). In a DM or
        group the message must be a root. The caller's read position (and the
        Seen by others see) moves only for a session or a token holding
        write:chat; a read-only token reads without marking, and so does
        `mark_read=false`, as on `GET /chat/rooms/{id}`. To mark a thread read
        without loading it, `POST …/thread/read`.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      - name: mark_read
        in: query
        description: false — read the thread without marking its replies read. Omitted
          or true — opening it marks them read, as before.
        schema:
          type: boolean
          default: true
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      - name: before_id
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Parent message with its replies (oldest first)
          content:
            application/json:
              schema:
                type: object
                properties:
                  parent:
                    "$ref": "#/components/schemas/ChatMessage"
                  replies:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  reply_count:
                    type: integer
                  has_more:
                    type: boolean
                  oldest_message_id:
                    type: integer
                    nullable: true
                  following:
                    type: boolean
                    description: Channel threads only; false in a DM or group.
                  last_read_reply_id:
                    type: integer
                    nullable: true
                    description: The caller's read position in the thread as it stood
                      before this request marked it read — the last reply they had
                      read. Null when they have none (never opened the thread, or
                      have no follow row). Clients draw a "New" line above the first
                      reply past it.
        '403':
          description: Not a room member
        '404':
          description: Parent not found or deleted; in a DM or group
          a reply:
  "/chat/rooms/{room_id}/messages/{message_id}/thread/read":
    post:
      tags:
      - Chat
      summary: Mark a channel thread read without loading it
      description: |
        Moves the caller's read position in the thread to its newest reply and
        reads the thread's notices (the "replied to a thread" bell row, an
        @mention in a reply) — exactly what opening it with
        `GET …/messages/{message_id}/thread` does, without the replies. A
        reply's id means its root. The Threads view's `unread` and
        `unread_thread_count` drop for a thread the caller follows. A write:
        needs write:chat, so a read-only token is refused (403) as on
        `PATCH /chat/rooms/{id}/mark_read`.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Marked read (also when there was nothing new)
        '401':
          description: Unauthorized
        '403':
          description: Not a room member
          Chat app disabled:
          or a token without write:chat:
        '404':
          description: "`message_not_found` (deleted or not in this room) / room not
            found"
        '422':
          description: "`not_a_channel` — threads are only in channels"
  "/chat/rooms/{room_id}/messages/{message_id}/acknowledge":
    post:
      tags:
      - Chat
      summary: Acknowledge an important / read-receipt message (idempotent)
      description: |
        Explicit per-message acknowledgement for messages posted with an
        `ack_type`. A repeat call is a no-op (`noop: true`). The answer carries
        the message again (`message`) — its body is no longer masked for the
        caller, so replace the local row with it. The person's own act: never
        recorded through an impersonation token or an admin's View-as.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  acknowledged_at:
                    type: string
                    format: date-time
                  workflow_participants_count:
                    type: integer
                    description: Eligible recipients who have acknowledged.
                  ack_eligible_count:
                    type: integer
                    description: Total eligible recipients (excludes the author).
                  noop:
                    type: boolean
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not a room member; `attestation_not_delegable` — an impersonation
            token (only the person themselves can acknowledge); `view_as` — under
            an admin's View-as
        '404':
          description: Room or message not found
        '422':
          description: "`ack_not_required` — the message does not require acknowledgement"
  "/chat/rooms/{room_id}/messages/{message_id}/acknowledgements":
    get:
      tags:
      - Chat
      summary: '"Read by X/Y" acknowledgement report'
      description: Partitions eligible recipients into acknowledged and pending.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Acknowledgement report
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  ack_type:
                    type: string
                    nullable: true
                    enum:
                    - important
                    - read_receipt
                    -
                  acknowledged:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/ChatUserSummary"
                      - type: object
                        properties:
                          user_id:
                            type: integer
                          acknowledged_at:
                            type: string
                            format: date-time
                  pending:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/ChatUserSummary"
                      - type: object
                        properties:
                          user_id:
                            type: integer
                  acknowledged_count:
                    type: integer
                    description: Everyone who acknowledged, named or not — use this,
                      never the list length. Only the named people while `names_hidden.count`
                      is null.
                  pending_count:
                    type: integer
                    description: Everyone still to acknowledge, named or not. Only
                      the named people while `names_hidden.count` is null.
                  roster_visible:
                    type: boolean
                    description: False when the room's member-visibility setting withholds
                      both name lists (counts still present).
                  can_remind:
                    type: boolean
                    description: True when Remind is available on this message for
                      the caller (the sender). POST …/remind still answers `too_soon`
                      within 24 hours of the last reminder.
                  remind_unavailable_reason:
                    type: string
                    nullable: true
                    enum:
                    - not_sender
                    - direct_chat
                    - sender_not_eligible
                    - feature_off
                    - nobody_pending
                    description: Why `can_remind` is false; null when it is true.
                      `sender_not_eligible` means the message was not recorded, when
                      it was sent, as sent by a manager or room admin (a message sent
                      before this server began recording that may lack the record
                      even when its sender was one — see `remind_rule_recorded`).
                  remind_rule_recorded:
                    type: boolean
                    description: False when `sender_not_eligible` says nothing about
                      the sender — a message sent before this server began recording
                      who may be chased (taken as the send time of the earliest chase-eligible
                      message still open — not deleted, and not yet reminded the most
                      times the automatic sweep allows; as those close the date can
                      only move later, which only drops an explanation, never adds
                      a wrong one), or one in a sample-data room. Say why Remind is
                      missing only when this is true.
                  names_hidden:
                    type: object
                    description: |
                      Present only when a works council hides names (General
                      Settings › Countries): the recipients left out of
                      `acknowledged` and `pending` — counted, never named.
                      While the hidden group is under the company's smallest reportable
                      group, `count` and `group_count` are null and `fewer_than` is that
                      group, "a few" (the status counts beside it count only the named
                      people) — the shape of `BroadcastNamesHidden`.
                    properties:
                      count:
                        type: integer
                        nullable: true
                      group_count:
                        type: integer
                        nullable: true
                        description: 'Null since #4546: a hidden group under the company''s
                          smallest group is "a few" (`fewer_than`). Kept for older
                          clients.'
                      fewer_than:
                        type: integer
                        description: 'Present only when the people it leaves out (or
                          the hidden group) are fewer than the company''s smallest
                          group: that number — they are "a few", and `count` / `group_count`
                          are null (#4546).'
                      reason:
                        type: string
                        example: Names are hidden for people in Germany (works council).
        '403':
          description: Not a room member
        '404':
          description: Room or message not found
  "/chat/rooms/{room_id}/messages/{message_id}/follow":
    post:
      tags:
      - Chat
      summary: Follow a channel thread (D10)
      description: |
        The caller is notified of new replies. A reply's id means its root.
        The thread's starter, anyone who replies and anyone @mentioned follow
        automatically; replying again follows again after an unfollow.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Following
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatThreadFollow"
        '403':
          description: Not a room member
        '404':
          description: "`message_not_found` / room not found"
        '422':
          description: "`not_a_channel` — threads are only in channels"
    delete:
      tags:
      - Chat
      summary: Unfollow a channel thread
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Not following
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatThreadFollow"
        '403':
          description: Not a room member
        '404':
          description: "`message_not_found` / room not found"
        '422':
          description: "`not_a_channel`"
  "/chat/rooms/{id}/archive":
    post:
      tags:
      - Chat
      summary: Archive a channel (D11)
      description: |
        The channel's admins and the company's Chat admins (who need not be in
        it). An archived channel is read-only, leaves everyone's conversation
        list and is listed under Archived in `GET /chat/channels`. Delete stays
        a separate action. Archiving asks `destructive:chat` (a final act, as
        Wikis archive); restoring asks `write:chat`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '403':
          description: Not allowed to archive it, or the token lacks `destructive:chat`
        '404':
          description: Room not found
        '422':
          description: "`not_a_channel` / `already_archived` (asked after permission)"
    delete:
      tags:
      - Chat
      summary: Restore an archived channel
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Restored
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '403':
          description: Not allowed to restore it
        '404':
          description: Room not found
        '422':
          description: "`not_archived`"
  "/chat/rooms/{id}/visibility":
    patch:
      tags:
      - Chat
      summary: Make a channel public or private (D9)
      description: |
        A channel's admins may make a public channel private; only a company
        Chat admin may make a private channel public — warn first that
        everything already said in it becomes readable by anyone who can browse
        channels. `changed: false` when it already had that visibility.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - is_public
              properties:
                is_public:
                  type: boolean
                  description: A JSON boolean or "true"/"false".
      responses:
        '200':
          description: Visibility set
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  changed:
                    type: boolean
        '403':
          description: Not allowed (only a Chat admin may make a channel public)
        '404':
          description: Room not found
        '422':
          description: "`is_public_required` / `not_a_channel` / `archived` (restore
            it first)"
  "/chat/rooms/{id}/convert_to_channel":
    post:
      tags:
      - Chat
      summary: Make a group a private channel (D5)
      description: |
        Where `conversion_required` (naming a group) and `group_full` (adding
        past the limit) send a client. Everyone stays, the history comes along
        and the caller becomes a channel admin. Needs the right to create
        channels. `user_ids` — the people who were waiting to be added — are
        added once it is a channel; a refusal to add them is reported in
        `members_error` beside the conversion, which stands.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                  description: The channel's name (normalised
                  unique in the company).:
                user_ids:
                  type: array
                  items:
                    type: integer
      responses:
        '200':
          description: Converted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  added:
                    type: array
                    items:
                      type: integer
                  rejoined:
                    type: array
                    items:
                      type: integer
                  members_error:
                    type: object
                    nullable: true
                    properties:
                      code:
                        type: string
                      message:
                        type: string
        '403':
          description: "`channel_not_allowed` (may not create channels) / `forbidden`
            (not in the group) / `information_barrier` (Chat Wave 4 — the company's
            information barriers keep these people apart; `error.message` is the sentence
            to show) / 503 `information_barrier_unavailable` (who is in a barrier
            group couldn't be read just now — try again) — checked for `user_ids`
            before the group changes, so it stays a group"
        '404':
          description: Room not found
        '422':
          description: "`not_a_group` / `name_invalid`"
  "/chat/channels":
    get:
      tags:
      - Chat
      summary: Browse Channels
      description: |
        Every public channel in the company and the private ones the caller
        is in. Each row is the room shape plus the directory's `member_count`
        (every active member) and `joined`; a channel the caller has not joined
        carries no `last_message_preview`. 30 a page. 403
        `group_chats_disabled` when group chats are off.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: Matches name or topic.
        schema:
          type: string
      - name: filter
        in: query
        schema:
          type: string
          enum:
          - all
          - mine
          - archived
          default: all
      - name: sort
        in: query
        schema:
          type: string
          enum:
          - members
          - activity
          - name
          - newest
          default: members
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
      responses:
        '200':
          description: A page of channels
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/ChatRoom"
                      - type: object
                        properties:
                          joined:
                            type: boolean
                          join_restriction:
                            type: object
                            nullable: true
                            description: 'Set when POST /chat/channels/{id}/join would
                              refuse it: the channel holds someone the company''s
                              information barriers keep the caller from. Draw no Join,
                              and show `message`. Null otherwise.'
                            properties:
                              code:
                                type: string
                                enum:
                                - information_barrier
                              message:
                                type: string
                  meta:
                    type: object
                    properties:
                      counts:
                        type: object
                        description: How many channels each filter shows (the filter
                          pills).
                        properties:
                          all:
                            type: integer
                          mine:
                            type: integer
                          archived:
                            type: integer
                      filter:
                        type: string
                      sort:
                        type: string
                      q:
                        type: string
                        nullable: true
                      page:
                        type: integer
                      per_page:
                        type: integer
                      has_more:
                        type: boolean
        '403':
          description: Chat app or group chats disabled
  "/chat/channels/{id}":
    get:
      tags:
      - Chat
      summary: One public channel
      description: |
        A public channel by id, whether or not the caller is in it — what a
        link to a channel opens in the app ("Join #name?" with its member
        count, then `POST /chat/channels/{id}/join`). The row is exactly one of
        `GET /chat/channels`' rows. Archived channels are returned too
        (`archived_at` is set; joining one answers 422 `archived`). Anything
        else — a private channel (whether or not the caller is in it), a
        group, a direct message, a deleted room, another company's — answers
        404 `room_not_found`, as `POST …/join` does. 403
        `group_chats_disabled` when group chats are off.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The channel
          content:
            application/json:
              schema:
                type: object
                properties:
                  channel:
                    allOf:
                    - "$ref": "#/components/schemas/ChatRoom"
                    - type: object
                      properties:
                        member_count:
                          type: integer
                          nullable: true
                          description: Every active member; null where the channel's
                            roster is kept to its admins.
                        joined:
                          type: boolean
                        join_restriction:
                          type: object
                          nullable: true
                          description: 'Set when POST /chat/channels/{id}/join would
                            refuse it: the channel holds someone the company''s information
                            barriers keep the caller from. Draw no Join, and show
                            `message`. Null otherwise.'
                          properties:
                            code:
                              type: string
                              enum:
                              - information_barrier
                            message:
                              type: string
        '403':
          description: Chat app or group chats disabled
        '404':
          description: "`room_not_found` — not a public channel of this company"
  "/chat/channels/similar":
    get:
      tags:
      - Chat
      summary: Channels like the one being named
      description: |
        Up to three channels the caller can see whose name or topic shares a
        word (three letters or more) with `q` — shown while naming a new
        channel so a duplicate is not made by accident.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
      responses:
        '200':
          description: Similar channels
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        topic:
                          type: string
                          nullable: true
                        is_public:
                          type: boolean
                        joined:
                          type: boolean
                          description: The caller is in it — offer Open; otherwise
                            offer Browse channels on it (`GET /chat/channels?q=<name>`),
                            where Join is.
                        member_count:
                          type: integer
                          nullable: true
                          description: Null where the channel's roster is kept to
                            its admins.
  "/chat/channels/avatar_suggestion":
    get:
      tags:
      - Chat
      summary: The picture a new channel with this name would get
      description: |
        The emoji and colour `POST /chat/rooms` stores for a channel created
        with this `name` and `topic` and no `avatar` — so a create dialog can
        preview the picture as it is typed — and the emoji its Shuffle offers.
        Writes nothing. A name that normalises to nothing answers
        `{ avatar: null, emoji_choices: [] }`. Only for people who may create
        channels (`GET /chat/config` → `can_create_channels`).
      security:
      - BearerAuth: []
      parameters:
      - name: name
        in: query
        required: true
        description: The channel name being typed.
        schema:
          type: string
      - name: topic
        in: query
        required: false
        description: The topic being typed; its words help pick the emoji.
        schema:
          type: string
      responses:
        '200':
          description: The suggested picture
          content:
            application/json:
              schema:
                type: object
                properties:
                  avatar:
                    type: object
                    nullable: true
                    properties:
                      emoji:
                        type: string
                        nullable: true
                        example: "\U0001F4E3"
                      color:
                        "$ref": "#/components/schemas/ChatChannelAvatarColor"
                  emoji_choices:
                    type: array
                    maxItems: 8
                    description: Up to eight emoji to offer, the best match first.
                    items:
                      type: string
                    example:
                    - "\U0001F4E3"
                    - "\U0001F4E2"
                    - "\U0001F4AC"
        '403':
          description: "`not_allowed` — may not create channels; `group_chats_disabled`
            — channels are turned off for the organization"
  "/chat/channels/groups":
    get:
      tags:
      - Chat
      summary: Groups a new channel may follow
      description: |
        Chat Wave 3 — the Create a channel form's Members → "Everyone in a
        group" list: the company's own groups a channel's members may come
        from, by name, whose name contains `q`. Never one whose people change
        on their own (on shift now or working today, near a place, recent and
        new hires, an attribute match on a dimension the clock moves — tenure,
        location now, clocked in), and never one that reaches nobody until a
        send names a place or a department (Location Managers, Location
        Staff); `POST /chat/rooms` refuses those with 422 `group_not_found`.
        The first 50; `has_more`
        says to narrow with `q`. Pass a group's `id` as `recipient_group_id`
        on `POST /chat/rooms` (`room_type: channel`). Only for people
        `GET /chat/config` → `can_create_synced_channels` is true for (a
        business admin or Chat admin who may create channels, under a token
        with `write:chat`) — anyone else gets 403 `channel_not_allowed`.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: Part of a group's name.
        schema:
          type: string
      responses:
        '200':
          description: Groups by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        type_label:
                          type: string
                          description: What kind of group it is, in words (NotificationRecipientGroup#type_label)
                            — e.g. Static, Attribute match.
                  has_more:
                    type: boolean
                    description: More groups match than were listed — narrow with
                      `q`.
        '403':
          description: "`channel_not_allowed` (may not make a channel whose members
            come from a group; `error.message` is the sentence) / `group_chats_disabled`
            / Chat app disabled"
  "/chat/channels/{id}/join":
    post:
      tags:
      - Chat
      summary: Join a public channel
      description: |
        A person who left comes back. `joined: false` when the caller was
        already a member (nothing changed). A private channel answers 404, as
        for a room that is not there.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Joined
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  joined:
                    type: boolean
        '403':
          description: "`group_chats_disabled` (channels are off for the company)
            / `forbidden` (not an active person here) / `information_barrier` (Chat
            Wave 4 — the company's information barriers keep these people apart; `error.message`
            is the sentence to show) / 503 `information_barrier_unavailable` (who
            is in a barrier group couldn't be read just now — try again) — the channel
            holds someone the caller may not message"
        '404':
          description: Not a public channel in this company
        '422':
          description: "`archived`"
  "/chat/threads":
    get:
      tags:
      - Chat
      summary: The Threads view — threads the caller follows (D10)
      description: |
        Channel threads the caller follows that have replies, in rooms they
        are still in, newest reply first, with `unread_thread_count` (the badge
        on "Threads") and `total_count` (every thread this list holds, not one
        page). Each row previews its newest replies (`latest_replies`, up to 2,
        oldest first). Listing never marks anything read; opening a thread
        (`GET …/messages/{id}/thread`) or `POST …/messages/{id}/thread/read`
        does.
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          default: 30
          maximum: 100
      - name: before
        in: query
        description: "`next_before` of the previous page"
        schema:
          type: integer
      responses:
        '200':
          description: Followed threads
          content:
            application/json:
              schema:
                type: object
                properties:
                  threads:
                    type: array
                    items:
                      type: object
                      properties:
                        root:
                          "$ref": "#/components/schemas/ChatMessage"
                        room:
                          type: object
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                            room_type:
                              type: string
                            is_public:
                              type: boolean
                        reply_count:
                          type: integer
                        unread:
                          type: integer
                        last_reply_at:
                          type: string
                          format: date-time
                        repliers:
                          type: array
                          items:
                            "$ref": "#/components/schemas/ChatUserSummary"
                        latest_replies:
                          type: array
                          description: The thread's newest replies (at most 2), oldest
                            first; deleted ones left out.
                          items:
                            type: object
                            properties:
                              id:
                                type: integer
                              user_id:
                                type: integer
                                nullable: true
                              user_name:
                                type: string
                                nullable: true
                              user_avatar_url:
                                type: string
                                nullable: true
                              user_avatar_updated_at:
                                type: integer
                                nullable: true
                                description: Epoch seconds the author's avatar last
                                  changed (cache-bust).
                              body:
                                type: string
                                description: As a ChatMessage's `body` — the read-receipt
                                  notice while `body_masked`; "" for an attachment-only
                                  reply.
                              body_masked:
                                type: boolean
                                description: A read-receipt reply the caller has not
                                  acknowledged yet.
                              has_attachments:
                                type: boolean
                              is_system:
                                type: boolean
                                description: A server notice — draw it as a ChatMessage's
                                  `is_system`.
                              author_is_ai:
                                type: boolean
                                description: Written by Ask AI — as a ChatMessage's
                                  `author_is_ai`.
                              created_at:
                                type: string
                                format: date-time
                  unread_thread_count:
                    type: integer
                  total_count:
                    type: integer
                    description: Threads the caller follows that this list holds (have
                      replies, in rooms they can open) — all pages.
                  next_before:
                    type: integer
                    nullable: true
                    description: Cursor for the next page; null on the last page
  "/chat/start_chat":
    post:
      tags:
      - Chat
      summary: Find-or-create a conversation from a user selection
      description: |
        One selected user → DM; two or more → group. Reuses an existing room
        with the identical member set unless `create_new_conv_always` is true.

        Decision D5: a group holds up to 20 people, the caller included (422
        `group_too_large`), and has no name of its own — it is named after its
        members. A typed `name` on a NEW conversation makes it a PRIVATE channel
        when the caller may create channels (`converted_to_channel: true`) and
        is otherwise not used (`notice` says so). The same rule as the web's
        Start a Chat and the phone's picker (Apps::Chat::StartChatService).

        The Create-a-Group modal's settings may ride at the top level of the
        body — the five permission codes of `PATCH /chat/rooms/{id}/permissions`;
        those left out take their defaults. They are written only on a group
        (or private channel) this call makes — not on a DM, nor on an existing
        conversation handed back. They are checked before anything is made: a
        code a setting does not take is 422
        `invalid_permission_setting`, and `S` from someone who is not a
        business admin is 403 `permission_tier_not_allowed`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
              - type: object
                required:
                - selected_user_ids
                properties:
                  selected_user_ids:
                    type: array
                    items:
                      type: integer
                  name:
                    type: string
                    description: Optional — makes a new conversation a private channel
                      when the caller may create channels (ignored for DMs).
                  create_new_conv_always:
                    type: boolean
                    description: Skip dedup and always create a new room.
                  photo_signed_id:
                    type: string
                    description: An imported list's photo (`photo.signed_id` from
                      `POST /chat/member_imports`), set on a group or private channel
                      this call creates.
              - "$ref": "#/components/schemas/ChatRoomPermissionCodes"
      responses:
        '200':
          description: Room found or created
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
                  created:
                    type: boolean
                  converted_to_channel:
                    type: boolean
                    description: A typed name made a new private channel.
                  notice:
                    type: string
                    nullable: true
                    description: Set when a typed name was not used for a new group.
                  photo_error:
                    type: string
                    nullable: true
                    description: Set when `photo_signed_id` was sent but not used
                      — it couldn't be added, the conversation already existed, or
                      it is a direct message (the conversation stands either way).
                      The sentence to show.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or DMs/group chats disabled, or `information_barrier`
            (Chat Wave 4 — the company's information barriers keep these people apart;
            `error.message` is the sentence to show) / 503 `information_barrier_unavailable`
            (who is in a barrier group couldn't be read just now — try again) — the
            caller and a picked person, or two picked people, are on opposite sides
            of a barrier (refused whether or not the conversation already exists);
            `permission_tier_not_allowed` — only a business admin may set `S`
        '422':
          description: No users selected / self-only / users not in business / `chat_not_open`
            (Chat isn't open to someone picked — `error.message` names them) / `group_too_large`
            / `name_invalid` (a channel already has that name) / `invalid_permission_setting`
            (a code that setting does not take)
  "/chat/member_imports":
    post:
      tags:
      - Chat
      summary: Import a member list — read it and match it to the company's members
      description: |
        The review behind "Import a list" in New message, Create a channel and
        Add members (web, phone and desktop share it). Send any list the
        person has as `content` — pasted text, or a file the client read as
        text (CSV, TSV, JSON, vCard, an email's recipients, copied spreadsheet
        cells…), up to 2 MB. One AI call reads the people and what the list
        says about itself (its name, topic, public or private, photo and
        settings); the matching is deterministic, on the server: email (only
        where the caller's directory shows email), employee ID, exact name,
        then close names offered as "did you mean". Only the caller's own
        content is sent to the AI, never the directory.

        Each row of `rows` is `ready` (one member, ticked), `pick` (several
        members share the name or address, or only close names — choose one
        of `candidates`, none is chosen for the person), `not_found`, or
        `unavailable` with `reason_code` — `you`, `already_member`,
        `deactivated`, `chat_not_open` (Chat isn't open to them),
        `information_barrier`, `barrier_room` (kept apart from someone in the
        conversation), `barrier_list` (kept apart from someone earlier on the
        list) — and `reason`, the sentence to show in the caller's language.

        Nothing is created. The client creates with the people confirmed
        through `POST /chat/start_chat` (a group, or a private channel past
        `conversation.group_member_limit` or with a name), `POST /chat/rooms`
        (`room_type: channel`) or `POST /chat/rooms/{id}/members`, passing the
        reviewed `settings.values` as the five permission codes and
        `photo.signed_id` as `photo_signed_id` (kept for a day). Settings and
        the photo are not reviewed for an existing conversation (`room_id`).

        Reading `content` spends AI credits and counts against an hourly limit
        shared with the web and phone (429 `rate_limited`); matching `people`
        the client already read, or copying a conversation's people
        (`from_room_id`), does not.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content:
                  type: string
                  description: The list as text, at most 2 MB. One of this, `people`
                    or `from_room_id`.
                people:
                  type: array
                  description: People already read (matched with no AI call). One
                    of this, `content` or `from_room_id`. The first 500 are read (`truncated`
                    says when there were more); each value is cut to 300 characters.
                  items:
                    type: object
                    properties:
                      name:
                        type: string
                      email:
                        type: string
                      employee_id:
                        type: string
                      job_title:
                        type: string
                      department:
                        type: string
                      location:
                        type: string
                      list:
                        type: string
                        description: Which of several lists the person is on.
                from_room_id:
                  type: integer
                  description: 'Instead of `content` or `people`: everyone in this
                    group or channel ("Add everyone from a channel"), sent to the
                    review with no AI call and no hourly limit — the person copying
                    left out, each row `match: conversation`, and the review''s `list.copied_from`
                    names it. Only a group or channel the person is in and whose members
                    they may see; GET /chat/member_imports/sources lists them. The
                    first 500 are copied (`truncated` says when there were more).'
                room_id:
                  type: integer
                  description: Adding to this group or channel (Add members). Omit
                    for a new conversation.
      responses:
        '200':
          description: The review
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatMemberImport"
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` (not in that conversation) / `not_allowed` (may
            not add people to it) / `ai_unavailable` (AI is off for Chat for this
            person) / `source_hidden` (`from_room_id` names a conversation the person
            is in whose members they may not see)"
        '404':
          description: "`room_not_found` (`room_id` names no conversation here, or
            `from_room_id` none the person is in — the same answer whatever the conversation
            is)"
        '422':
          description: "`invalid_room_type` (a direct message) / `invalid_source`
            (`from_room_id` is not a group or channel) / `org_synced` (its members
            come from a group, location, team or shift — `error.synced_from`) / `ai_usage_blocked`
            (the AI budget is paused or spent; `error.message` is the sentence) /
            `no_people` (nothing in it reads as a person) / `blank` / `content_too_large`
            / `too_long` / `too_many` (more than one read can take — split it) / `ai_failed`"
        '429':
          description: "`rate_limited` — the hourly limit; `error.message` says when
            it lifts. Counted only for a read of `content` that passed every other
            check."
  "/chat/member_imports/sources":
    get:
      tags:
      - Chat
      summary: The groups and channels whose people the caller may copy
      description: |
        For "Add everyone from a channel": the caller's groups and channels
        whose members they may see (the room's "Who can see members?", with
        the token's own limits on acting as a company admin) with someone else
        in them, most recently active first, at most 200 — `truncated` says
        more qualify. Pass a room's `id` as `from_room_id` to POST
        /chat/member_imports.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: query
        required: false
        schema:
          type: integer
        description: The conversation people are being added to — left out of the
          list.
      responses:
        '200':
          description: The rooms
          content:
            application/json:
              schema:
                type: object
                properties:
                  rooms:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        kind:
                          type: string
                          enum:
                          - group
                          - channel
                        title:
                          type: string
                          description: A channel's "#name", or a group's title as
                            the conversation list names it.
                        others:
                          type: integer
                          description: How many people besides the caller are in it.
                  truncated:
                    type: boolean
                    description: More rooms qualify than the 200 listed (the most
                      recently active are).
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
          or token lacks read:chat:
        '404':
          description: "`room_not_found` (`room_id` names no conversation here)"
  "/chat/users/search":
    get:
      tags:
      - Chat
      summary: User search for starting chats (recent-DM boosted)
      description: |
        Searches business users by name, email, employee id, job title, office
        location, and phone; users the caller recently messaged rank higher.
        People on the other side of one of the company's information barriers
        from the caller are not offered (Chat Wave 4 — starting a chat with
        them answers 403 `information_barrier`).
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        schema:
          type: string
      - name: active_only
        in: query
        description: |
          `false` also returns people who have left the business (deactivated
          here, or whose account is off). Honoured ONLY for a caller who is an
          admin or above in the business; for anyone else it is ignored and
          the active directory is returned.
        schema:
          type: boolean
          default: true
      - name: roles[]
        in: query
        description: |
          Role filter. Honoured ONLY for a caller who is manager-or-above in
          the business; for anyone else it is silently ignored (the request
          still succeeds and returns the default set, so pickers never
          break). Defaults to member/manager/admin/super_admin; guests are
          excluded either way.
        schema:
          type: array
          items:
            type: string
      - name: thread_id
        in: query
        description: |
          A Messages direct-message thread (Inbox → Messages, not a Chat room)
          the caller is in: results are limited to its participants. A thread
          that is not the caller's, or does not exist, returns no rows. Chat's
          information-barrier filter does not apply here — the participants
          are that app's.
        schema:
          type: integer
      - name: page
        in: query
        description: |
          Page 1 additionally carries the caller's recent-DM contacts, prepended
          and de-duplicated against the paged window, so it returns more rows
          than `per_page`. Size buffers off the returned array, not off
          `page x 50`.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: all
        in: query
        description: |
          Return up to 500 ranked matches in one response; `page` is ignored.
          NOT "all matches without paging" - that wording was wrong. When the
          directory is larger than the cap, `pagination.more` is true and the
          remainder is only reachable by narrowing `q`.
        schema:
          type: boolean
      - name: chat_users
        in: query
        description: |
          Release 3 · `1` (or `true`): only people Chat is open to — the web
          pickers' `chat_users=1`, the rule a backup for an Important message
          is refused on (`backup_without_chat`). Pick a backup with it.
        schema:
          type: boolean
      - name: exclude_ids
        in: query
        description: |
          Release 3 · user ids never to offer (the room's members, people
          already picked), comma-separated: `exclude_ids=4,9`
          (`exclude_ids[]=4&exclude_ids[]=9` works too, for a few). At most
          1,000 (422 `too_many_exclude_ids` past it) — send a long list
          comma-separated: the server refuses a query string over 10 KB
          before it is read. A narrowing only — every door still checks whom
          it adds.
        schema:
          type: string
          example: '4,9'
      responses:
        '200':
          description: Ranked user matches
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        text:
                          type: string
                          description: Same as name (typeahead convention).
                        email:
                          type: string
                          nullable: true
                          description: Null where the People directory does not show
                            the caller email (and for someone with none).
                        avatar_url:
                          type: string
                        meta:
                          type: object
                          description: Context metadata for the picker.
                  pagination:
                    type: object
                    properties:
                      more:
                        type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
        '422':
          description: "`too_many_exclude_ids` — more than 1,000 ids in `exclude_ids`"
  "/chat/mentions":
    get:
      tags:
      - Chat
      summary: The caller's mentions inbox
      description: |
        Cross-room mentions of the caller (or a single room's with `room_id`),
        newest first, cursor-paginated by mention id. Each row names its
        conversation (`room_name`, `room_type`), says whether it is `unread`
        — past the caller's read position in the room, or in the thread for a
        reply kept there; in a room they are still in that is not archived —
        and, for a mention in a channel thread reply, the thread to open it in
        (`thread_root_id`) and whether the reply stayed there (`thread_only`).
        `unread_count` is the list's unread mentions (one room's with
        `room_id`), the same count as the rooms' `unread_mention_count`.
        Reading this list marks nothing read.
      security:
      - BearerAuth: []
      parameters:
      - name: before_id
        in: query
        description: Return mentions strictly older than this id (0 = newest page).
        schema:
          type: integer
          default: 0
      - name: limit
        in: query
        schema:
          type: integer
          default: 50
          maximum: 100
      - name: room_id
        in: query
        description: Scope to one conversation.
        schema:
          type: integer
      responses:
        '200':
          description: Mention page
          content:
            application/json:
              schema:
                type: object
                properties:
                  mentions:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        message_id:
                          type: integer
                        room_id:
                          type: integer
                        room_name:
                          type: string
                          nullable: true
                          description: 'The conversation''s title as the conversation
                            list draws it — the room payload''s `display_name`, in
                            the caller''s language: a DM by the other person''s name,
                            a group or channel by its own, a group with none by its
                            people where the caller may see who is in it ("Ann, Bo,
                            Cy and 2 more"), else "Untitled group". Null only for
                            a mention whose message is gone.'
                        room_type:
                          type: string
                          enum:
                          - direct
                          - group
                          - channel
                          nullable: true
                        unread:
                          type: boolean
                          description: Not read yet (the rule behind the rooms' `unread_mention_count`).
                            Always false in a room the caller has left or an archived
                            channel.
                        thread_root_id:
                          type: integer
                          nullable: true
                          description: For a mention in a channel thread reply, the
                            thread's first message — open `GET /chat/rooms/{room_id}/messages/{thread_root_id}/thread`.
                            Null otherwise (a DM or group quote reply included).
                        thread_only:
                          type: boolean
                          description: The thread reply was kept in its thread (not
                            also sent to the channel timeline). False when `thread_root_id`
                            is null.
                        room_accessible:
                          type: boolean
                          description: False when the caller has since left that room
                            (or its message is gone) — the mention is kept as history,
                            but the room endpoints refuse it. Show the row; do not
                            link it.
                        user_id:
                          type: integer
                          description: The MENTIONED user (always the caller).
                        sender_id:
                          type: integer
                          nullable: true
                          description: Author of the mentioning message.
                        sender_name:
                          type: string
                          nullable: true
                          description: Author's display name, for the inbox row.
                        sender_avatar_url:
                          type: string
                          nullable: true
                          description: Author's avatar (falls back to a generated
                            initials image when no photo is set).
                        sender_avatar_updated_at:
                          type: integer
                          nullable: true
                          description: Epoch seconds the author's avatar last changed
                            — cache-bust token for URL-caching clients.
                        body_excerpt:
                          type: string
                        created_at:
                          type: string
                          format: date-time
                  has_more:
                    type: boolean
                  oldest_mention_id:
                    type: integer
                    nullable: true
                  unread_count:
                    type: integer
                    description: Unread mentions in this list — all pages; one room's
                      with `room_id`.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/search":
    get:
      tags:
      - Chat
      summary: Message search (full-text or semantic)
      description: |
        Searches message content across the caller's rooms. `mode=semantic`
        adds vector-embedding scoring when the business has semantic search
        enabled (falls back to FTS otherwise).
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
      - name: mode
        in: query
        schema:
          type: string
          enum:
          - fts
          - semantic
          default: fts
      - name: room_id
        in: query
        schema:
          type: integer
      - name: sender_id
        in: query
        schema:
          type: integer
      - name: date_from
        in: query
        schema:
          type: string
          format: date
      - name: date_to
        in: query
        schema:
          type: string
          format: date
      - name: page
        in: query
        schema:
          type: integer
          default: 1
          minimum: 1
          maximum: 50
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        room_id:
                          type: integer
                        user_id:
                          type: integer
                        body:
                          type: string
                        parent_message_id:
                          type: integer
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
                        snippet_html:
                          type: string
                          nullable: true
                          description: Highlighted match excerpt.
                  mode:
                    type: string
                    enum:
                    - fts
                    - semantic
                  has_more:
                    type: boolean
                  query:
                    type: string
                  semantic_available:
                    type: boolean
                  semantic_attempted:
                    type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/search/senders":
    get:
      tags:
      - Chat
      summary: Sender autocomplete for the search filter
      description: Users who have sent messages in the caller's active rooms.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        description: |
          Partial name (or email, where the People directory shows the caller email). Below 2 characters NO senders are returned -
          the endpoint does not fall back to an unfiltered alphabetical list,
          because a picker renders those rows as if they were matches.
        schema:
          type: string
      responses:
        '200':
          description: Matching senders
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatUserSummary"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/search/conversations":
    get:
      tags:
      - Chat
      summary: Find conversations by name or message content
      description: |
        Searches the caller's active conversations by group/channel name, DM
        partner name, or full message history (max 50 rooms). Message-content
        matching uses the same full-text search as `/chat/search`, but returns
        room objects so chat-list clients can render the matching conversation.
        Not paged: `has_more` says more than 50 conversations matched, and
        `content_truncated` that content matching stopped at its 50 rooms.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
      - name: include_people
        in: query
        description: |
          `true` or `1`: the answer also carries `people` — the first page of
          `GET /chat/users/search` for the same `q` (default filters), so a
          unified search panel needs one request. Empty below 2 characters.
        schema:
          type: string
          enum:
          - 'true'
          - '1'
      responses:
        '200':
          description: Conversations matching by name or message content
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations:
                    type: array
                    maxItems: 50
                    items:
                      "$ref": "#/components/schemas/ChatRoom"
                  content_truncated:
                    type: boolean
                    description: Message-content matching looked only at the 50 rooms
                      whose newest match is newest, so an older match elsewhere may
                      be missing — say so rather than "no conversation mentions it".
                  has_more:
                    type: boolean
                    description: More than the 50 conversations returned matched;
                      narrow `q`.
                  people:
                    type: array
                    description: Only with `include_people` — rows shaped like `GET
                      /chat/users/search` `results`.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        text:
                          type: string
                        email:
                          type: string
                          nullable: true
                        avatar_url:
                          type: string
                        meta:
                          type: object
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/settings":
    get:
      tags:
      - Chat
      summary: Business chat settings + caller admin flag
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings:
                    "$ref": "#/components/schemas/ChatSettings"
                  admin:
                    type: boolean
                    description: Whether the caller can update settings.
                  read_only_keys:
                    type: array
                    items:
                      type: string
                    description: Settings in `settings` this caller may read but not
                      change — a Chat app admin who is not a company admin gets the
                      four company-admin keys (PATCH refuses them with 403 `company_admin_only`);
                      empty otherwise.
                  retention:
                    type: object
                    description: How long each kind of conversation keeps its messages,
                      in days (30–3650); null = forever (the default). Every night,
                      messages older than this are hidden from everyone, and permanently
                      deleted 30 days later; anything under legal hold is kept for
                      the company's records (people no longer see it). A room's own
                      `retention_days` says the same for that room. Set by a company
                      admin on the web Chat settings page only; PATCH here does not
                      write it.
                    properties:
                      direct:
                        type: integer
                        nullable: true
                        minimum: 30
                        maximum: 3650
                      group:
                        type: integer
                        nullable: true
                        minimum: 30
                        maximum: 3650
                      channel:
                        type: integer
                        nullable: true
                        minimum: 30
                        maximum: 3650
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
    patch:
      tags:
      - Chat
      summary: Update business chat settings (admin only)
      description: Send any subset of keys; omitted keys are unchanged. Integer values
        are clamped to safe ranges. `content_moderation_enabled`, `ai_moderation_enabled`,
        `moderation_action` and `chat_video_subtitles_enabled` are written only by
        a company admin; a Chat app admin who sends any of them with a CHANGED value
        gets 403 `company_admin_only` (the keys in `error.keys`) and nothing is saved.
        Sent back unchanged, they are ignored.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ChatSettings"
      responses:
        '200':
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings:
                    "$ref": "#/components/schemas/ChatSettings"
        '401':
          description: Unauthorized
        '403':
          description: Not an admin or chat app disabled
        '422':
          description: Validation failure
  "/chat/presence/heartbeat":
    post:
      tags:
      - Chat
      summary: Presence heartbeat
      description: |
        Marks the caller connected; clients call this periodically. `state`
        says whether they are active (`online`, the default) or away from the
        keyboard (`idle` — peers draw the amber dot); `away` is read as `idle`
        and anything else as `online`.

        The OneMango Desktop app also sends `in_use_for`: how many more
        seconds it counts as in use (5 minutes after the last keyboard or
        mouse activity, 0 while the screen is locked). While it is in use, a
        company with "One interruption per item" on shows each notification
        there as a desktop pop-up instead of pushing the phone.
      security:
      - BearerAuth: []
      parameters:
      - name: state
        in: query
        description: Also accepted in the body.
        schema:
          type: string
          enum:
          - online
          - idle
          default: online
      - name: in_use_for
        in: query
        description: |
          Desktop app only (read from an Electron request). Seconds the app
          stays in use, 0–300; 0 says it is not in use now. Also accepted in
          the body.
        schema:
          type: integer
          minimum: 0
          maximum: 300
      - name: device
        in: query
        description: |
          Desktop app only. Which desktop is beating (an id for this run of
          the app), so one computer locking never clears another the person
          is using. Also accepted in the body.
        schema:
          type: string
          maxLength: 64
      responses:
        '200':
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  state:
                    type: string
                    enum:
                    - online
                    - idle
                    description: The state recorded.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/presence/online":
    get:
      tags:
      - Chat
      summary: Who is online
      description: |
        Either room-scoped (`room_id` — caller must be a member, and may see
        the room's member list) or an explicit `user_ids` list
        (comma-separated, max 200). Room mode reads the 200 longest-standing
        members; `truncated` says there are more — ask about the rest in
        `user_ids` batches.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: query
        schema:
          type: integer
      - name: user_ids
        in: query
        description: Comma-separated user ids (max 200); used when room_id is omitted.
        schema:
          type: string
      responses:
        '200':
          description: Online user ids
          content:
            application/json:
              schema:
                type: object
                properties:
                  online_user_ids:
                    type: array
                    description: Connected — an idle user is in it.
                    items:
                      type: integer
                  presence_states:
                    type: object
                    description: User id (as a string) → `online` or `idle`; an id
                      left out is offline.
                    additionalProperties:
                      type: string
                      enum:
                      - online
                      - idle
                    example:
                      '42': online
                      '57': idle
                  room_id:
                    type: integer
                    description: Echoed only in room_id mode.
                  truncated:
                    type: boolean
                    description: Room mode only — the room has more than 200 active
                      members
                    and only the first 200 were read.:
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a member of the room, or (room mode) the
            room's member list is kept to admins; or Chat app disabled"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`too_many_user_ids` — more than 200 user_ids"
  "/chat/pusher/auth":
    post:
      tags:
      - Chat
      summary: Authorize a Pusher channel subscription
      description: |
        Validates the channel against the caller's memberships and returns a
        signed Pusher auth token. Supported channels: `private-room-<id>`,
        `private-user-inbox-<user_id>-business-<business_id>`,
        `presence-room-<id>` (presence responses also include `channel_data`),
        and `private-business-<business_id>-chat-settings` (`settings:updated`
        — the company's Chat settings changed; any member of that business).

        `room:updated` on `private-room-<id>` (`{ room_id }`): a channel's or
        group's name, topic, photo, visibility (`is_public`), archived state,
        kind (a group turned into a channel), settings (who can post, add
        members, see members, send Important messages; who may reply in
        threads; the Ask AI suggestions switch) or the source its members
        follow changed — from any door (the web, this API, the phone, Ask AI,
        the company's org syncs). Re-read the room with `GET /chat/rooms/{id}?mark_read=false`;
        the payload carries no state because the room's `can_*` flags and
        restrictions differ per person. Sent on the room channel only, never
        to inbox channels — a list with the room closed picks the change up on
        its next fetch.

        `reminder:due` on the caller's own inbox channel
        (`{ reminder_id, room_id, message_id, room_name, excerpt }`): a reminder
        they set (`POST …/messages/{message_id}/reminder`) is due and its bell
        row was written — see that endpoint.

        `status:changed` on the caller's own inbox channel (`{ user_id }`):
        their status or pause changed from any door (the web, the phone, this
        API, Ask AI) — re-read `GET /chat/status`. Sent to the person's own
        channel only, never for a no-op save, nor when a status or pause runs
        out (time those from `expires_at` / `dnd_until`).

        `scheduled_messages:changed` on the caller's own inbox channel
        (`{ room_id, scheduled_message_id, state }`, Release 3): one of their
        scheduled messages changed — `state` is `created`, `updated` (its
        words or time changed, or a failed one was rescheduled), `sent`,
        `failed` (its `error` says why — also when the message a scheduled
        reply answered was permanently deleted: it stays as "Not sent", no
        longer a reply) or `cancelled` — from any door (the
        web, the phone, this API, Ask AI) or the minute job that sends what is
        due. Re-read `GET /chat/rooms/{room_id}/scheduled_messages` (and the
        room's "N scheduled messages" line). Not sent while a message is being
        sent (`sending`), and only to its author; a sent one also arrives as an
        ordinary `message:created`.

        `message:created` (room and inbox channels) carries `permalink`, the
        same absolute link as the message's `permalink` in REST answers, and
        `forwarded_from`, the same object as the REST message's (null when the
        message is not a forward; on an event marked `body_omitted: true` it
        may be null too when the quote alone was too long — re-read the
        message then).

        `message:deleted` on `private-room-<id>` — a message was deleted (by
        its author, a room admin, or moderation):
        `{ id, room_id, parent_message_id, in_thread, parent_thread }`.
        `parent_message_id` and `in_thread` are the deleted message's own
        (the same keys as a `ChatMessage`; `parent_message_id` is null for a
        top-level message). With `in_thread` (a reply in a channel thread),
        `parent_thread` is the ROOT's thread line as it stands after the
        delete — `{ reply_count, last_reply_at, repliers }`, the same keys and
        count as the root's `thread` in `GET …/messages?threads=true`, without
        the per-viewer `following`: not-deleted replies, whether kept in the
        thread or also sent to the channel. Update the root's "N replies" /
        last-reply line from it even when the thread is closed and the reply
        is not on screen; `reply_count` 0 means nobody's reply is left.
        `parent_thread` is null when `in_thread` is false.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
              - channel_name
              - socket_id
              properties:
                channel_name:
                  type: string
                  example: private-room-42
                socket_id:
                  type: string
      responses:
        '200':
          description: Subscription authorized
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth:
                    type: string
                  channel_data:
                    type: string
                    description: JSON-encoded user info — presence channels only.
        '400':
          description: Missing channel_name or socket_id
        '401':
          description: Unauthorized
        '403':
          description: Unsupported channel
          not a member:
          or cross-business mismatch:
        '503':
          description: Pusher not configured on this server
  "/chat/pusher/config":
    get:
      tags:
      - Chat
      summary: Pusher bootstrap config (legacy flat shape)
      description: Backward-compatible alias — new clients should call GET /chat/config.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Pusher client config
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatPusherConfig"
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/config":
    get:
      tags:
      - Chat
      summary: Unified client bootstrap (pusher + chat settings)
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Combined config
          content:
            application/json:
              schema:
                type: object
                properties:
                  pusher:
                    "$ref": "#/components/schemas/ChatPusherConfig"
                  chat_settings:
                    "$ref": "#/components/schemas/ChatSettings"
                  ask_ai_account:
                    allOf:
                    - "$ref": "#/components/schemas/ChatAskAiIdentity"
                    nullable: true
                    description: The @Ask AI MENTION principal. Present only while
                      the tenant's "Enable @Ask AI in chat conversations" setting
                      (chat_settings.ask_ai_enabled) is on. null ⇒ do not offer the
                      @Ask AI mention. Carries the extra history_search flag.
                  ask_ai_chat:
                    allOf:
                    - "$ref": "#/components/schemas/ChatAskAiIdentity"
                    nullable: true
                    description: The PINNED private Ask AI conversation (each user's
                      own assistant chat). Gated on this USER's access to the Ask
                      AI app — NOT on chat_settings.ask_ai_enabled, which controls
                      only the in-room mention. null ⇒ do not show the pinned Ask
                      AI chat. Read this key for the pinned entry; never infer it
                      from ask_ai_account. `id` is null until the assistant principal
                      has been provisioned — render the name/avatar fallbacks as usual.
                  reactions:
                    type: array
                    description: Server-driven reaction set (also at GET /chat/reactions)
                      — the company's quick set, not reactions on a message.
                    items:
                      type: object
                      properties:
                        emoji:
                          type: string
                          example: "\U0001F44D"
                        key:
                          type: string
                          nullable: true
                          example: like
                          description: The reaction's catalog key; null for a glyph
                            outside the catalog.
                        label:
                          type: string
                          example: Like
                          description: For tooltips and screen readers.
                        icon_url:
                          type: string
                          nullable: true
                          example: https://acme.workforce.mangoapps.com/assets/reactions/like-1a2b3c.svg
                          description: Absolute URL of the drawn icon the web shows
                            for this reaction. null ⇒ draw the emoji glyph. Draw the
                            glyph too when the image does not load.
                  reaction_icons:
                    type: object
                    additionalProperties:
                      type: string
                    example:
                      "\U0001F44D": https://acme.workforce.mangoapps.com/assets/reactions/like-1a2b3c.svg
                      "\U0001F602": https://acme.workforce.mangoapps.com/assets/reactions/haha-4d5e6f.svg
                    description: "Every emoji the platform draws as an icon → that
                      icon's absolute URL —\nwider than `reactions`: a message keeps
                      reactions the palette no longer\noffers, older glyphs that mean
                      the same reaction (\U0001F602 is Haha, ❤ without\nVS16 is Love)
                      and emoji sent from the full picker. Draw a message's\nreaction
                      from here; an emoji that is not a key has no icon and is drawn\nas
                      its glyph.\n"
                  reactions_accept_any_emoji:
                    type: boolean
                    description: 'Chat Wave 2: `reactions` is the quick set; POST
                      …/reactions also takes any single emoji, so a client may offer
                      a full emoji picker behind "More".'
                  klipy:
                    type: object
                    description: GIF-picker (KLIPY) bootstrap. enabled=false ⇒ hide
                      the picker. New clients search through GET /chat/gifs and need
                      no key; `api_key` stays for the clients that still search KLIPY
                      directly.
                    properties:
                      api_key:
                        type: string
                        nullable: true
                      base_url:
                        type: string
                      enabled:
                        type: boolean
                  attachment_rules:
                    type: object
                    description: Which file extensions an attachment may carry, so
                      a client can refuse a file at pick time instead of uploading
                      it to a 422. Advisory — POST /chat/rooms/{room_id}/media enforces
                      every rule (and judges the real bytes). Match on the lowercased
                      extension with its dot. Sentences are in the caller's display
                      language; show them as written.
                    properties:
                      allowed_extensions:
                        type: array
                        items:
                          type: string
                          example: ".pdf"
                        description: Every extension chat accepts. A file with no
                          extension is also accepted.
                      refused_extensions:
                        type: object
                        additionalProperties:
                          type: string
                        description: Extension → the sentence to show for it, e.g.
                          ".html" → "HTML files can't be shared in chat because they
                          can run code when opened. Zip the file or share a PDF instead."
                      unsupported_extension_message:
                        type: string
                        description: The sentence for any other extension not in allowed_extensions
                          — replace %{ext} with the file's extension.
                  can_create_channels:
                    type: boolean
                    description: Whether the caller may create a channel ("Who can
                      create channels"), on a tenant with group chats on and a credential
                      that may write. Show Create Channel only when true.
                  can_create_synced_channels:
                    type: boolean
                    description: 'Whether the caller may make a channel whose members
                      are everyone in a group (Create a channel → Members → "Everyone
                      in a group"): a business admin or Chat admin who may create
                      channels, under a token with `write:chat` (not only `write:own_chat`).
                      Offer that choice only when true; list the groups with GET /chat/channels/groups
                      and send the pick as `recipient_group_id` on POST /chat/rooms.'
                  group_member_limit:
                    type: integer
                    example: 20
                    description: A group holds up to this many people, the creator
                      included (D5).
                  member_import_available:
                    type: boolean
                    description: 'Whether to offer "Import a list" (POST /chat/member_imports)
                      in New message, Create a channel and Add members: AI is on for
                      Chat, the caller''s AI usage is not blocked, and the token may
                      write. Draw the link only when true.'
                  member_copy_available:
                    type: boolean
                    description: 'Whether to offer "Add everyone from a channel" (the
                      rooms from GET /chat/member_imports/sources, then POST /chat/member_imports
                      with from_room_id): no AI, so true whenever the token may write
                      — where member_import_available is false, offer this choice
                      alone.'
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/ask_ai/identity":
    get:
      tags:
      - Chat
      summary: The Ask AI assistant's identity (name, icon, description, greeting,
        history tip)
      description: 'Any chat member. Answered when the Ask AI app is on for the business
        (the persona is an Ask AI-wide setting) or Chat''s "@Ask AI in chat" toggle
        is on. A read only — it never creates the assistant''s account. Before that
        account exists the answer is the default identity: `id` null, the default
        name, `avatar_url`, `avatar_updated_at`, `description` and `history_tip` null,
        and the persona''s greeting.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Current identity
          content:
            application/json:
              schema:
                type: object
                properties:
                  ask_ai_identity:
                    "$ref": "#/components/schemas/ChatAskAiIdentity"
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: Chat app disabled, or Ask AI not enabled (error.code "not_available")
    patch:
      tags:
      - Chat
      summary: Update the Ask AI assistant's identity (admin only)
      description: 'Send any subset of fields as multipart/form-data; omitted fields
        are unchanged and an empty string clears a text field (except `name`, which
        cannot be blank). The PERSONA — name, description, greeting, avatar — is the
        same setting as Ask AI › Guidelines › Assistant: it needs an organization
        admin and the Ask AI app on. `history_tip` is Chat-only: an organization admin
        or a Chat app admin, with Chat''s "@Ask AI in chat" toggle on.'
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 40
                  description: Letters and numbers in any script, spaces and . ' ’
                    & -, starting with a letter or number; not the name of a person
                    in the organization. The assistant introduces itself by it in
                    every answer.
                description:
                  type: string
                  maxLength: 1000
                  description: Subtitle shown under the name in Chat.
                greeting:
                  type: string
                  maxLength: 500
                  description: The assistant's opening line; empty ⇒ the built-in
                    welcome.
                history_tip:
                  type: string
                  maxLength: 1000
                  description: One-time tip on the first history-grounded answer in
                    a Chat room; empty ⇒ the built-in tip.
                avatar:
                  type: string
                  format: binary
                  description: Icon — PNG, JPG, GIF or WebP, under 5 MB. An empty
                    part leaves the icon unchanged.
                remove_avatar:
                  type: boolean
                  description: Clear the custom icon (a persona change — the same
                    organization-admin rule).
      responses:
        '200':
          description: Updated identity
          content:
            application/json:
              schema:
                type: object
                properties:
                  ask_ai_identity:
                    "$ref": "#/components/schemas/ChatAskAiIdentity"
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: Not permitted for these fields (error.code "forbidden"), Ask
            AI / the Chat toggle not enabled (error.code "not_available"), or Ask
            AI is not available to this business (error.code "no_account" / "missing_business")
        '409':
          description: Another account in the business already uses that name (error.code
            "name_taken")
        '422':
          description: Invalid name, text too long, or an icon of the wrong type or
            size
        '500':
          description: The identity could not be saved (error.code "update_failed")
            — try again
  "/chat/gifs":
    get:
      tags:
      - Chat
      summary: Search GIFs (KLIPY) through the server
      description: |
        The web composer's GIF search, for a client that holds no KLIPY key:
        the same content filter and the same per-person id KLIPY is sent.
        Trending when `q` is blank. Pick one with POST
        /chat/rooms/{room_id}/media/gif, sending the GIF's `slug` and the `q`
        it was found with. A picked GIF is a file in the room, so the
        organization's file-sharing switch applies here too.
      security:
      - bearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: The search; blank for trending.
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: One page of GIFs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  gifs:
                    type: array
                    items:
                      type: object
                      properties:
                        slug:
                          type: string
                          description: What POST …/media/gif takes.
                        key:
                          type: string
                        title:
                          type: string
                          nullable: true
                        preview_url:
                          type: string
                          format: uri
                        width:
                          type: integer
                          nullable: true
                        height:
                          type: integer
                          nullable: true
                  page:
                    type: integer
                  has_next:
                    type: boolean
                  provider:
                    type: string
                    enum:
                    - klipy
        '401':
          description: Not signed in
        '403':
          description: file_uploads_disabled — sharing files is off for the organization;
            or Chat is not open to the caller (`read:chat` required).
        '502':
          description: gif_unavailable — KLIPY did not answer.
        '503':
          description: gif_not_configured — this deployment has no KLIPY key.
  "/chat/rooms/{room_id}/media/gif":
    post:
      tags:
      - Chat
      summary: Add a GIF picked from KLIPY as an attachment
      description: |
        The server looks the KLIPY `slug` up (GET /chat/gifs is how a client
        searches without a key), downloads the rendition under
        chat's attachment size cap and stores it like an upload — the same
        `media` answer as POST …/media. Send its id in `media_ids` with the next
        message. Same gates as an upload: file uploads on, a member who may
        post or reply in a thread here, the per-room attachment cap.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - slug
              properties:
                slug:
                  type: string
                  description: The slug KLIPY's search answered
                  token and all.:
                q:
                  type: string
                  description: The search the GIF was picked from (KLIPY's share signal).
      responses:
        '201':
          description: Stored and ready.
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    "$ref": "#/components/schemas/ChatMedia"
        '403':
          description: Not a member (forbidden), may not post here (im_not_allowed;
            `information_barrier` in a direct message the company's information barriers
            keep apart), or file uploads off (file_uploads_disabled).
        '404':
          description: Room not found
        '422':
          description: gif_not_found / gif_invalid_slug / gif_too_large / gif_unavailable
            / attachment_cap_reached — error.message is the sentence to show.
        '503':
          description: gif_not_configured — this deployment has no KLIPY key.
  "/chat/rooms/{room_id}/voice_notes":
    post:
      tags:
      - Chat
      summary: Send a voice note
      description: |
        The recording becomes a message: the audio is its attachment (an
        `audio/*` file every client plays) and its words are transcribed —
        the message's `transcript` is `{ status: "pending" }` until Whisper
        answers, then `completed` with `text` (or `failed`, with `error` when
        there is a sentence for the person). The change arrives as
        `message:updated` with `update_kind: "transcript"`. Same posting gate as
        POST …/messages; `parent_message_id` in a channel makes it a thread
        reply. Up to 25 MB; webm, mp4/m4a, mp3, ogg or wav.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - audio
              properties:
                audio:
                  type: string
                  format: binary
                parent_message_id:
                  type: integer
                also_send_to_channel:
                  type: boolean
                push_to_talk:
                  type: boolean
                  description: "Held to talk (walkie-talkie): the message's `push_to_talk`
                    is true, and members whose `walkie_talkie` is on hear it play
                    as it arrives. Always to the conversation — not with `parent_message_id`.
                    A clip is at most 60 seconds: send its `duration_ms` (required
                    with push_to_talk, at most 61000) in a compressed format of at
                    most 3 MB — the server refuses a longer or larger clip with `push_to_talk_too_long`,
                    and one with no `duration_ms` with `push_to_talk_duration_missing`
                    (both 422). The push reads \"\U0001F399 <name>: <transcript or
                    'Voice message'>\"."
                duration_ms:
                  type: integer
                  minimum: 1
                  description: How long the recording is, in milliseconds, as the
                    recorder measured it. Required with `push_to_talk` (at most 61000);
                    optional for an ordinary voice note. Release 3 · kept on the audio
                    attachment and answered as its `duration_ms` (a WebM file does
                    not say how long it is); a value that is not a length (0, negative,
                    over a day) keeps none and never refuses the voice note.
      responses:
        '201':
          description: The message it became.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '403':
          description: Not a member, may not post here (im_not_allowed; `information_barrier`
            in a direct message the company's information barriers keep apart), or
            file uploads off.
        '404':
          description: Room not found
        '422':
          description: invalid_audio, file_too_large, attachment_cap_reached, parent_not_found,
            push_to_talk_reply (push_to_talk with parent_message_id), push_to_talk_duration_missing
            (push_to_talk without duration_ms) or push_to_talk_too_long (a push_to_talk
            clip over 61000 ms or 3 MB) — error.message is the sentence.
  "/chat/rooms/{room_id}/draft":
    get:
      tags:
      - Chat
      summary: The caller's draft for this room
      description: The same draft the web composer keeps as the person types, so a
        message started on one device is there on another. With `parent_message_id`,
        the reply draft of that channel thread — the one the web thread panel and
        the phone thread page keep.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: parent_message_id
        in: query
        required: false
        schema:
          type: integer
        description: 'A channel thread''s reply draft instead of the room''s: the
          message replied to (a reply resolves to its thread''s root).'
      responses:
        '200':
          description: The draft (`body` null when there is none).
          content:
            application/json:
              schema:
                type: object
                properties:
                  draft:
                    "$ref": "#/components/schemas/ChatDraft"
        '403':
          description: Not a member
        '404':
          description: Room not found, or parent_not_found (the thread's message is
            not in the room or was deleted)
        '422':
          description: threads_not_available — `parent_message_id` on a room that
            is not a channel
    put:
      tags:
      - Chat
      summary: Save the caller's draft for this room
      description: "`body` is what a send would post (mention markup included). A
        blank body clears the draft. Sending a message in the room clears it too.
        With `parent_message_id` (query or body) it is that channel thread's reply
        draft, cleared by a reply in the thread (POST …/messages with `parent_message_id`
        and `in_thread`)."
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: parent_message_id
        in: query
        required: false
        schema:
          type: integer
        description: 'A channel thread''s reply draft instead of the room''s: the
          message replied to (a reply resolves to its thread''s root).'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                  maxLength: 10000
                mention_user_ids:
                  type: array
                  items:
                    type: integer
                parent_message_id:
                  type: integer
                  description: Save the reply draft of this channel thread instead
                    of the room's.
      responses:
        '200':
          description: Saved (or cleared).
          content:
            application/json:
              schema:
                type: object
                properties:
                  draft:
                    "$ref": "#/components/schemas/ChatDraft"
        '403':
          description: Not a member
        '404':
          description: Room not found, or parent_not_found (the thread's message is
            not in the room or was deleted)
        '422':
          description: body_too_long, or threads_not_available — `parent_message_id`
            on a room that is not a channel
    delete:
      tags:
      - Chat
      summary: Clear the caller's draft for this room
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: parent_message_id
        in: query
        required: false
        schema:
          type: integer
        description: 'A channel thread''s reply draft instead of the room''s: the
          message replied to (a reply resolves to its thread''s root).'
      responses:
        '204':
          description: Cleared
        '403':
          description: Not a member
        '404':
          description: Room not found, or parent_not_found (the thread's message is
            not in the room or was deleted)
        '422':
          description: threads_not_available — `parent_message_id` on a room that
            is not a channel
  "/chat/rooms/{room_id}/polls":
    post:
      tags:
      - Chat
      summary: Post a poll
      description: A message whose body is the question, with the poll on it (`poll`
        on the message). Same posting gate as POST …/messages.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - question
              - options
              properties:
                question:
                  type: string
                  maxLength: 200
                options:
                  type: array
                  minItems: 2
                  maxItems: 10
                  items:
                    type: string
                    maxLength: 200
                  description: Distinct; blanks are dropped.
                allow_multiple:
                  type: boolean
                  default: false
      responses:
        '201':
          description: The poll message.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
        '202':
          description: "`still_sending` — an `{ error }` body and no message: the
            poll is still being posted and arrives as `message:created`. Do not retry
            (it would post twice)."
        '403':
          description: Not a member, or may not post here (im_not_allowed; `information_barrier`
            in a direct message the company's information barriers keep apart)
        '422':
          description: question_blank, question_too_long, too_few_options, too_many_options,
            option_too_long or duplicate_options.
        '500':
          description: "`send_failed` — the poll was not posted; safe to try again."
  "/chat/rooms/{room_id}/polls/{id}/vote":
    post:
      tags:
      - Chat
      summary: Set the caller's answer to a poll
      description: Sets the caller's votes to exactly `option_indexes` — one at most
        unless `allow_multiple`; `[]` takes the vote back. Members who may react here;
        an open poll only. Broadcasts `message:updated` with `update_kind "poll"`.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The poll id (`poll.id`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                option_indexes:
                  type: array
                  items:
                    type: integer
                    minimum: 0
      responses:
        '200':
          description: The poll as the caller now sees it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll:
                    "$ref": "#/components/schemas/ChatPoll"
        '403':
          description: im_not_allowed; `information_barrier` in a direct message the
            company's information barriers keep apart
        '404':
          description: poll_not_found
        '422':
          description: poll_closed, invalid_option or one_answer_only.
  "/chat/rooms/{room_id}/polls/{id}/close":
    post:
      tags:
      - Chat
      summary: Close a poll
      description: Its author or a room admin. A closed poll shows results only and
        can't be reopened, so closing asks the destructive:chat scope.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The closed poll.
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll:
                    "$ref": "#/components/schemas/ChatPoll"
        '403':
          description: not_allowed; read_only (an archived channel or a frozen DM);
            `information_barrier` (a direct message the company's information barriers
            keep apart)
        '404':
          description: poll_not_found
  "/chat/rooms/{room_id}/scheduled_messages":
    get:
      tags:
      - Chat
      summary: The caller's scheduled messages in this room
      description: |
        Still to send — scheduled, sending, and failed (with `error`) — soonest
        first, at most 100. Only their author sees them.

        Release 3 · re-read it on `scheduled_messages:changed` (the caller's
        inbox channel, `{ room_id, scheduled_message_id, state }` — see
        `POST /chat/pusher/auth`): a change from any device, door or the minute
        job that sends what is due.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  scheduled_messages:
                    type: array
                    maxItems: 100
                    items:
                      "$ref": "#/components/schemas/ChatScheduledMessage"
                  has_more:
                    type: boolean
                    description: The caller has more than the 100 returned here.
    post:
      tags:
      - Chat
      summary: Schedule a message
      description: Text only, from a minute to a year ahead. Posted at `send_at` as
        if sent then; the author's right to post is checked again then (a refusal
        leaves it `failed` with `error`, and a bell notice). `parent_message_id` in
        a channel makes it a thread reply. Clears the caller's draft for the room.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              - send_at
              properties:
                body:
                  type: string
                  maxLength: 10000
                send_at:
                  type: string
                  format: date-time
                parent_message_id:
                  type: integer
                also_send_to_channel:
                  type: boolean
      responses:
        '201':
          description: Scheduled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  scheduled_message:
                    "$ref": "#/components/schemas/ChatScheduledMessage"
        '403':
          description: Not a member, or may not post here (im_not_allowed; `information_barrier`
            in a direct message the company's information barriers keep apart)
        '422':
          description: invalid_body, invalid_send_at, parent_not_found, broadcast_not_allowed,
            or too_many_scheduled (the caller already has 100 open scheduled messages
            in this conversation).
  "/chat/scheduled_messages/{id}":
    patch:
      tags:
      - Chat
      summary: Change a scheduled message's words or time
      description: The author's, while scheduled or failed; a failed one is scheduled
        again.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                send_at:
                  type: string
                  format: date-time
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  scheduled_message:
                    "$ref": "#/components/schemas/ChatScheduledMessage"
        '404':
          description: not_found
        '409':
          description: not_changeable — already sent or cancelled
        '422':
          description: invalid_body or invalid_send_at
    delete:
      tags:
      - Chat
      summary: Cancel a scheduled message
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Cancelled
        '404':
          description: not_found
        '409':
          description: not_changeable
  "/chat/scheduled_messages/{id}/send_now":
    post:
      tags:
      - Chat
      summary: Send a scheduled message now
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Sent — `status` "sent" with `sent_message_id`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  scheduled_message:
                    "$ref": "#/components/schemas/ChatScheduledMessage"
        '404':
          description: not_found
        '409':
          description: not_changeable
        '422':
          description: 'Not sent now. Either the `{ scheduled_message }` body — `status`
            is "failed" and `error` says why — or an `{ error }` body with code `still_sending`:
            the post is still going through, the row stays "sending" and is posted
            (or marked failed) shortly; Send now meanwhile answers 409 `not_changeable`.'
  "/chat/rooms/{room_id}/mention_audience":
    post:
      tags:
      - Chat
      summary: How many people a message's @channel / @here / @group would notify
      description: |
        `@channel` notifies everyone in the conversation, `@here` everyone in it
        online now, and `@[Group name](group:<id>)` the group's members who are
        in the conversation — never the sender, and nothing in a DM. Ask before
        sending and confirm past `confirm_above` ("This will notify N people.
        Send anyway?"). `allowed: false` means POST …/messages answers 422
        `broadcast_not_allowed` (Chat settings say who may use them; room
        admins always may).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                  allowed:
                    type: boolean
                  confirm_above:
                    type: integer
                    example: 10
        '403':
          description: Not a member
        '404':
          description: Room not found
  "/chat/rooms/{id}/notifications":
    patch:
      tags:
      - Chat
      summary: Set the caller's notification level for a room
      description: |
        `all` notifies about every message, `mentions` only about what is
        addressed to the caller (an @mention, a direct message, a reply in a
        thread they follow, a message asking them to acknowledge it), `none`
        about nothing — the room still counts unread messages but leaves the
        app-level unread total. `default` follows the room's default again: a
        channel of more than 50 active members defaults to `mentions`, every
        other room to `all`. A timed mute (`/mute`) still silences everything
        while it lasts. The caller's own membership only.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - level
              properties:
                level:
                  type: string
                  enum:
                  - all
                  - mentions
                  - none
                  - default
      responses:
        '200':
          description: Level saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  notify_level:
                    type: string
                    enum:
                    - all
                    - mentions
                    - none
                    description: The level in force.
                  notify_level_setting:
                    type: string
                    nullable: true
                    enum:
                    - all
                    - mentions
                    - none
                    description: What the caller chose; null follows the room's default.
                  default_notify_level:
                    type: string
                    enum:
                    - all
                    - mentions
                    description: The level with no setting (mentions for a channel
                      of more than 50 members, else all) — the room payload's `default_notify_level`.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
        '422':
          description: "`invalid_level` — missing or not one of the four; `archived`
            — an archived channel is read-only, so it has no level to set."
  "/chat/rooms/{room_id}/messages/{message_id}/link_preview":
    delete:
      tags:
      - Chat
      summary: Remove a message's link preview card (sender only)
      description: |
        Stores `link_preview: {}` and tells every open room over
        `message:updated` (with `link_preview_only: true`; the body is
        unchanged). An edit that changes the message's first link fetches a
        new card.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  link_preview:
                    type: object
                    description: Always empty.
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a room member, or not the sender."
        '404':
          description: "`message_not_found`"
  "/chat/status":
    get:
      tags:
      - Chat
      summary: The caller's status and Do Not Disturb
      description: |
        Whenever what this answer shows changes — from any door: the web
        "Set a status" drawer and Pause, the phone, `PUT`/`DELETE /chat/status`
        or Ask AI — the caller's own devices hear `status:changed`
        (`{ user_id }`) on `private-user-inbox-<user_id>-business-<business_id>`;
        re-read this endpoint. It goes to the caller's own channel only (other
        people's statuses are re-read with `GET /chat/users/statuses`), and is
        not sent for a save that changes nothing shown, when a status or pause
        runs out (time those from `expires_at` / `dnd_until`), or for
        `out_of_office`, which follows approved leave.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The caller's status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    "$ref": "#/components/schemas/ChatOwnStatus"
        '401':
          description: Unauthorized
    put:
      tags:
      - Chat
      summary: Set the caller's status and/or pause notifications
      description: |
        Only the keys sent change. `emoji` and `text` are the status, as a pair
        (one left out is blank; both blank clears it), clearing at
        `clear_after` (default `never`) or an exact `expires_at`. `dnd_for`
        pauses notifications (`tomorrow` = 8:00 tomorrow in the caller's zone;
        `off` resumes), or an exact `dnd_until` (at most 7 days; blank
        resumes). While paused no chat push is sent; bell notifications still
        arrive. Out of office is not set here: it follows approved leave. A
        change tells the caller's own devices (`status:changed` — see `GET`).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                emoji:
                  type: string
                  maxLength: 32
                  description: One emoji.
                text:
                  type: string
                  maxLength: 100
                clear_after:
                  type: string
                  enum:
                  - 30_minutes
                  - 1_hour
                  - 4_hours
                  - today
                  - this_week
                  - never
                expires_at:
                  type: string
                  format: date-time
                dnd_for:
                  type: string
                  enum:
                  - 30_minutes
                  - 1_hour
                  - 2_hours
                  - tomorrow
                  - 'off'
                dnd_until:
                  type: string
                  format: date-time
                  nullable: true
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    "$ref": "#/components/schemas/ChatOwnStatus"
        '401':
          description: Unauthorized
        '422':
          description: "`invalid_status` — nothing sent, or a value refused (the message
            says which)."
    delete:
      tags:
      - Chat
      summary: Clear the caller's status and resume notifications
      description: |
        Clears both. When something was showing, the caller's own devices hear
        `status:changed` (see `GET`).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    "$ref": "#/components/schemas/ChatOwnStatus"
        '401':
          description: Unauthorized
  "/chat/users/statuses":
    get:
      tags:
      - Chat
      summary: Other people's status
      description: |
        One entry per id that shows something (a status, paused notifications,
        or approved leave today); an id with nothing to show, outside this
        company, or of someone who shares no conversation with the caller, is
        absent. A pause shows to others only as `dnd: true` —
        never until when. At most 200 ids.
      security:
      - BearerAuth: []
      parameters:
      - name: ids
        in: query
        required: true
        schema:
          type: string
        description: Comma-separated user ids (or ids[]=…).
      responses:
        '200':
          description: Statuses
          content:
            application/json:
              schema:
                type: object
                properties:
                  statuses:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id:
                          type: integer
                        emoji:
                          type: string
                          nullable: true
                        text:
                          type: string
                          nullable: true
                        expires_at:
                          type: string
                          format: date-time
                          nullable: true
                        dnd:
                          type: boolean
                          description: Notifications paused.
                        out_of_office:
                          type: boolean
                          description: On approved leave today.
        '401':
          description: Unauthorized
        '422':
          description: "`too_many_ids`"
  "/chat/rooms/{id}/walkie_talkie":
    patch:
      tags:
      - Chat
      summary: Turn the caller's walkie-talkie on or off for a room
      description: |
        While it is on and the room is open, a push-to-talk clip (a voice note
        sent with `push_to_talk: true`; the message's `push_to_talk`) plays as
        it arrives — the web and phone pages do, and a native client that
        honours the switch should. The caller's own membership only; any
        active member may set it (listening needs no right to post), except in
        an archived channel and in the caller's own Note to self (nobody else
        is there to hear). The room's `walkie_talkie` and the caller's own
        member row carry it.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - 'on'
              properties:
                'on':
                  type: boolean
      responses:
        '200':
          description: Switch saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  walkie_talkie:
                    type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled or not a room member
        '404':
          description: Room not found
        '422':
          description: "`missing_on` — no `on`; `archived` — an archived channel takes
            no clips; `note_to_self` — the caller's own Note to self has nobody else
            to hear one; `no_clips` — turning it ON where nobody can send a clip (the
            Needs you or Notifications conversation, a frozen one-to-one, one whose
            other person left or that an information barrier blocks). Turning it off
            is never refused."
  "/chat/rooms/{room_id}/messages/{message_id}/pin":
    post:
      tags:
      - Chat
      summary: Pin a message to its conversation
      description: |
        Anyone who may post in the room may pin, and its admins (an archived
        channel is read-only). Not a room notice or a removed message. At most
        25 pins per room — the 26th is refused with `pin_limit`. Pinning posts
        the room line "X pinned a message."; every change is broadcast as
        `message:pinned` on `private-room-<id>` ({ id, room_id, pinned,
        pinned_at, pinned_by: { id, name } | null, pinned_count }). Pinning a
        pinned message changes nothing.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The message, now pinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
                  pinned_count:
                    type: integer
        '403':
          description: Not a room member, or may not pin here (`forbidden`; `information_barrier`
            in a direct message the company's information barriers keep apart)
        '404':
          description: "`message_not_found` / room not found"
        '422':
          description: "`not_pinnable` (a room notice) / `pin_limit` (25 already pinned)"
    delete:
      tags:
      - Chat
      summary: Unpin a message
      description: Same rule as pinning. Posts no room line. A write, not a delete-level
        act.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The message, now unpinned
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    "$ref": "#/components/schemas/ChatMessage"
                  pinned_count:
                    type: integer
        '403':
          description: Not a room member
          or may not pin here:
        '404':
          description: "`message_not_found` / room not found"
  "/chat/rooms/{room_id}/messages/{message_id}/save":
    post:
      tags:
      - Chat
      summary: Save a message for later
      description: |
        A saved message is the caller's own bookmark (it also appears in their
        Bookmarks). Not a room notice or a removed message. Idempotent.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  saved:
                    type: boolean
                    enum:
                    - true
        '403':
          description: Not a room member
        '404':
          description: "`message_not_found` / room not found"
        '422':
          description: "`not_savable` — a room notice"
    delete:
      tags:
      - Chat
      summary: Remove a message from saved
      description: Always allowed — it is the caller's own row, whether or not they
        can still open the message. Idempotent.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Not saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                  saved:
                    type: boolean
                    enum:
                    - false
  "/chat/rooms/{room_id}/messages/{message_id}/reminder":
    post:
      tags:
      - Chat
      summary: Remind me about this message
      description: |
        Sets a reminder for the CALLER, delivered when due as a bell row and a
        push ("Reminder: <author>'s message in <room>", the message's words —
        masked for a read-receipt request not yet acknowledged — and its
        permalink; the "Chat · Reminders" notification setting). No push while
        the caller's notifications are paused (`PUT /chat/status` `dnd_for`);
        the bell row still arrives. Presets are read in the caller's time zone.
        A message removed meanwhile, or a room the caller left, closes the
        reminder with no notice. The caller holds one reminder per message:
        asking again MOVES the open one to the new time (200, `moved: true`)
        rather than adding a second.

        Once the bell row is written, the caller's own devices hear
        `reminder:due` on `private-user-inbox-<user_id>-business-<business_id>`:
        `{ reminder_id, room_id, message_id, room_name, excerpt }` — `excerpt`
        the bell row's words (the read-receipt mask until acknowledged),
        `room_name` the conversation as the caller sees it — the room payload's
        `display_name` (an unnamed group by its people, never null). It is sent while notifications are paused too: whether to show
        a notification for it is the client's, from the caller's own pause
        (`GET /chat/status` `dnd`). (`reminder`, not `remind`: …/remind is the
        sender's chase of unacknowledged recipients.)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                preset:
                  type: string
                  enum:
                  - in_20_minutes
                  - in_1_hour
                  - in_3_hours
                  - tomorrow_9am
                  - next_monday_9am
                at:
                  type: string
                  format: date-time
                  description: Instead of a preset — in the future
                  within a year.:
      responses:
        '200':
          description: The caller's open reminder on this message was moved to the
            new time (`moved` true); same body as 201.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatMessageReminderSet"
        '201':
          description: Reminder set (`moved` false)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatMessageReminderSet"
        '403':
          description: Not a room member
        '404':
          description: "`message_not_found` / room not found"
        '422':
          description: "`invalid_preset` / `invalid_time` / `not_remindable` (a chat
            notice) / `message_not_found`"
  "/chat/rooms/{room_id}/messages/{message_id}/forward":
    post:
      tags:
      - Chat
      summary: Forward a message to other conversations
      description: |
        Posts a NEW message, as the caller, in each target: the note, then
        "Forwarded from <author> in <room>:", then the original's words as plain
        text (mentions do not travel as mentions), then "(N attachments in the
        original)" — attachments stay in the original room. Up to 5 targets;
        each must be a conversation the caller can post in, and one that is not
        is `refused` with the reason while the rest still go.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - room_ids
              properties:
                room_ids:
                  type: array
                  items:
                    type: integer
                  minItems: 1
                  maxItems: 5
                note:
                  type: string
                  maxLength: 2000
      responses:
        '200':
          description: What went where
          content:
            application/json:
              schema:
                type: object
                properties:
                  forwarded:
                    type: array
                    items:
                      type: object
                      properties:
                        room_id:
                          type: integer
                        message_id:
                          type: integer
                  refused:
                    type: array
                    items:
                      type: object
                      properties:
                        room_id:
                          type: integer
                        reason:
                          type: string
                  message:
                    type: string
                    description: 'The sentence to show, in the caller''s language
                      — the web drawer''s own: "Forwarded to 2 conversations.", then,
                      for each refused conversation the caller is in, "Not sent to
                      #ops: <reason>" (one they are not in is not named — its reason
                      alone), or "Nothing was forwarded." when every target was refused.'
        '403':
          description: Not a member of the message's room
        '404':
          description: "`message_not_found` / room not found"
        '422':
          description: "`not_forwardable` (a room notice, a removed message, a read-receipt
            request the caller has not acknowledged) / `no_targets` / `too_many_targets`
            / `note_too_long`"
  "/chat/rooms/{room_id}/messages/{message_id}/mark_unread":
    post:
      tags:
      - Chat
      summary: Mark a conversation unread from this message
      description: |
        Moves the caller's read position back to just before the message and
        recounts their unread (live timeline messages by others); their other
        clients get `inbox:unread-changed`. Opening the room (or PATCH
        …/mark_read) reads it again — so a client should not mark the room read
        until the person sends, scrolls back to the newest message, or reopens it.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Marked unread
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  message_id:
                    type: integer
                  last_read_message_id:
                    type: integer
                    description: The previous timeline message
                    or 0.:
                  unread_count:
                    type: integer
        '403':
          description: Not a room member
        '404':
          description: "`message_not_found` (also a removed message) / room not found"
        '422':
          description: "`own_message` — the caller wrote it; `not_allowed` — it could
            not be marked unread (the caller stopped being an active member of the
            room meanwhile)"
  "/chat/rooms/{id}/pins":
    get:
      tags:
      - Chat
      summary: The room's pinned messages
      description: Newest pinned first (at most 25). Members only.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Pinned messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  pins:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessage"
                  pinned_count:
                    type: integer
        '403':
          description: Not a room member
        '404':
          description: Room not found
  "/chat/rooms/{id}/on_shift":
    get:
      tags:
      - Chat
      summary: Who in the room is on shift now
      description: |
        The room's members on shift right now — clocked in, else on a shift
        they are expected at that covers now (the platform's one meaning of
        "on shift"), with the site they are on shift at. Members only, and only
        where the room's "Who can see members?" lets the caller. `@onshift` in a
        message notifies these people (a broadcast mention — the
        `can_broadcast_mentions` capability). `people` lists at most 200, by
        name; `total_count` is everyone, and `truncated` says the list was cut.

        Release 3 · each person says what they are working now — `role`,
        `shift_starts_at`, `shift_ends_at`, `shift_time_zone` (their current
        shift; see `ChatOnShiftPerson`). The web panel draws them as
        "Shift Lead · 6:00 AM – 2:30 PM" on the shift's clock, labelled when it
        is not the viewer's ("… 4:00 PM PDT"). The three are null for a person
        whose shift the caller may not see (their own, their reports', their
        locations'; admins see all).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The members on shift now
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  on_shift_count:
                    type: integer
                  people:
                    type: array
                    maxItems: 200
                    items:
                      "$ref": "#/components/schemas/ChatOnShiftPerson"
                  total_count:
                    type: integer
                    description: Everyone on shift
                    before the 200 cap.:
                  truncated:
                    type: boolean
                    description: More than 200 — `people` was cut.
        '403':
          description: Not a room member (`forbidden`), or the roster is kept to admins
            (`members_hidden`)
        '404':
          description: Room not found
  "/chat/on_shift":
    get:
      tags:
      - Chat
      summary: Who holds a role on shift now at a location
      description: |
        "Message who's on shift": the people working `role` (a role type, as
        on a shift — `GET /chat/on_shift/options` lists them) at the location
        or a site beneath it right now. The location is one of the caller's
        own (the locations they are assigned to and the sites beneath them) —
        any active location for a manager or above, or a Chat admin. Start a chat with them through
        `POST /chat/start_chat` (one person → a DM, several → a group of up to
        20; `group_too_large` says when there are more). When nobody holds the
        role now, `people` is empty and `room` is the location's channel, when
        there is one the caller can open. `people` lists at most 200, by name;
        `total_count` is everyone, and `truncated` says the list was cut.

        Release 3 · each person says what they are working now — `role`,
        `shift_starts_at`, `shift_ends_at`, `shift_time_zone`: their current
        shift at this location or a site beneath it (see `ChatOnShiftPerson`
        — null where the caller may not see it), as the web drawer shows it.
        `location` stays the location asked about.
      security:
      - BearerAuth: []
      parameters:
      - name: location_id
        in: query
        required: true
        schema:
          type: integer
      - name: role
        in: query
        required: false
        schema:
          type: string
        description: A role type; blank = anyone on shift there.
      responses:
        '200':
          description: Who that is right now
          content:
            application/json:
              schema:
                type: object
                properties:
                  location:
                    type: object
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  role:
                    type: string
                    nullable: true
                  people:
                    type: array
                    maxItems: 200
                    items:
                      "$ref": "#/components/schemas/ChatOnShiftPerson"
                  total_count:
                    type: integer
                    description: Everyone who holds the role there now (after the
                      information-barrier cut)
                    before the 200 cap.:
                  truncated:
                    type: boolean
                    description: More than 200 — `people` was cut.
                  group_too_large:
                    type: boolean
                    description: More people than a group holds (20 with the caller).
                  barred_count:
                    type: integer
                    description: How many people on shift there now are left out of
                      `people` because the company's information barriers keep the
                      caller from them — say so ("Nobody you can message is on shift…"
                      when `people` is empty, else "N people on shift can't be messaged
                      under your company's policy."), never that nobody is on shift.
                  room:
                    nullable: true
                    allOf:
                    - "$ref": "#/components/schemas/ChatRoom"
                    description: The location's channel, offered when nobody is on
                      shift in that role (or there are too many for a group).
        '403':
          description: "`start_chat_disabled` — direct messages and group chats are
            both off; `on_shift_unavailable` — none of Shifts & Scheduling, Shift
            Marketplace or Time & Attendance is on, so nobody can be on shift"
        '404':
          description: "`location_not_found` — no such active location, or not one
            of the caller's (a manager or above, or a Chat admin, may ask about any)"
        '422':
          description: "`location_required`"
  "/chat/on_shift/options":
    get:
      tags:
      - Chat
      summary: Locations and roles to pick for "Message who's on shift"
      description: |
        The caller's own locations by name (any active location for a manager
        or above, or a Chat admin), at most 1,000; `locations_truncated` says
        there are more, and `q` finds them by name.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: Only the locations whose name holds every word of this, in any
          order (accents ignored).
      responses:
        '200':
          description: What to pick from
          content:
            application/json:
              schema:
                type: object
                properties:
                  locations:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  locations_truncated:
                    type: boolean
                    description: There are more locations than listed — search them
                      with `q`.
                  roles:
                    type: array
                    items:
                      type: string
                    description: The role types people here hold (the list on-shift
                      groups offer)
                    as stored — the values `GET /chat/on_shift?role=` takes.:
                  role_options:
                    type: array
                    description: 'Release 3 · the same roles to draw a picker from:
                      `value` as stored (send it as `?role=`), `label` the name people
                      read (a stored key `crew_lead` reads "Crew Lead") — what a person''s
                      `role` says and the web picker shows.'
                    items:
                      type: object
                      properties:
                        value:
                          type: string
                          example: crew_lead
                        label:
                          type: string
                          example: Crew Lead
        '403':
          description: "`start_chat_disabled` — direct messages and group chats are
            both off; `on_shift_unavailable` — none of Shifts & Scheduling, Shift
            Marketplace or Time & Attendance is on, so nobody can be on shift"
  "/chat/rooms/{id}/org_sync":
    delete:
      tags:
      - Chat
      summary: Stop a channel syncing its members
      description: |
        A channel that follows a group, a location, a team or a shift stops
        following it. Everyone in it stays; from then on people are added and
        removed by hand (`org_source` and `synced_from` become null), and one
        line in the channel says who stopped it. Chat never makes another
        channel for that location or manager. The channel's admins and the
        company's Chat admins (`can_stop_org_sync`). Nothing is deleted, so it
        asks `write:chat`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The channel, no longer synced
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    "$ref": "#/components/schemas/ChatRoom"
        '403':
          description: "`forbidden` — not the channel's admin or a Chat admin"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`not_a_channel`, `archived` (restore it first) or `not_synced`
            (its members already aren't synced)"
  "/chat/rooms/{id}/legal_hold":
    post:
      tags:
      - Chat
      summary: Place a legal hold on a conversation
      description: |
        Keeps everything in the conversation for the company's records until
        the hold is lifted: nothing in it is permanently deleted, and it can't
        be deleted for everyone (422 `legal_hold`). Messages past the company's
        retention window are still hidden from people, as everywhere. People
        can still delete their own messages; the words are kept. Only
        business (company) admins see that a room is on hold (`on_legal_hold`).
        A business admin under write:chat, whether or not they are in the
        conversation: a conversation is held by its id without joining it, and
        nothing in it tells its members (`can_manage_legal_hold` on the room
        payload says the room's own menu offers it to an admin who is in it).
        The hold is listed in Admin › Retention & Legal Holds with every other
        hold, which can place it the same way.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - reason
              properties:
                reason:
                  type: string
                  maxLength: 1000
                  description: The matter the hold is for.
      responses:
        '200':
          description: Placed
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    allOf:
                    - "$ref": "#/components/schemas/ChatRoom"
                    nullable: true
                    description: The room for a caller who is in it; null for a business
                      admin who is not (what is said in it is never returned to someone
                      outside it).
                  legal_hold:
                    "$ref": "#/components/schemas/ChatLegalHold"
        '403':
          description: "`forbidden` — not a business admin, or a token that may not
            act as one"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`reason_required`, `already_held` (one hold on a conversation
            at a time), or `invalid`"
    delete:
      tags:
      - Chat
      summary: Lift the legal hold on a conversation
      description: |
        Lifts the hold placed on this conversation; the company's retention
        settings delete its hidden messages for good again. A hold on someone in it (or on one of its
        messages) is lifted in Admin › Retention & Legal Holds, and keeps the
        room held (`on_legal_hold` stays true). Nothing is deleted, so it asks
        write:chat. A reason is required, as on every door that lifts a hold
        (the web drawer, the phone, Admin › Retention & Legal Holds).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: lift_reason
        in: query
        required: true
        schema:
          type: string
          minLength: 1
          maxLength: 1000
        description: Why it is lifted (kept on the hold). Required.
      responses:
        '200':
          description: Lifted
          content:
            application/json:
              schema:
                type: object
                properties:
                  room:
                    allOf:
                    - "$ref": "#/components/schemas/ChatRoom"
                    nullable: true
                    description: The room for a caller who is in it; null for a business
                      admin who is not (what is said in it is never returned to someone
                      outside it).
                  legal_hold:
                    "$ref": "#/components/schemas/ChatLegalHold"
        '403':
          description: "`forbidden` — not a business admin, or a token that may not
            act as one"
        '404':
          description: "`room_not_found`"
        '422':
          description: '`not_held` — no hold is placed on this conversation; `reason_required`
            — no `lift_reason` was given ("Give a reason for lifting the hold.")'
  "/chat/saved":
    get:
      tags:
      - Chat
      summary: The caller's saved messages
      description: |
        Newest saved first, paged by cursor (`before` = the previous page's
        `next_before`). A saved message the caller can no longer open (a room
        they left, a message removed) comes back `available: false` with no
        `message` or `room` — the save is still theirs to remove.
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          default: 30
          maximum: 100
      - name: before
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Saved messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  saved:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                          description: The save's id.
                        saved_at:
                          type: string
                          format: date-time
                        available:
                          type: boolean
                        message_id:
                          type: integer
                        room:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              nullable: true
                            room_type:
                              type: string
                              enum:
                              - direct
                              - group
                              - channel
                        message:
                          nullable: true
                          allOf:
                          - "$ref": "#/components/schemas/ChatMessage"
                  next_before:
                    type: integer
                    nullable: true
  "/chat/saved/{message_id}":
    delete:
      tags:
      - Chat
      summary: Remove a saved message by its id
      description: |
        The Saved view's Remove: takes the caller's own save of the message off
        without naming its room, so it works for a row that came back
        `available: false` (a room the caller left, or that was deleted) as for
        any other. The same removal as `DELETE /chat/rooms/{room_id}/messages/{message_id}/save`.
        Idempotent — a message the caller has not saved answers 204 too.
      security:
      - BearerAuth: []
      parameters:
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: Not saved (any more)
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
          or a token without `write:chat`:
  "/chat/reminders":
    get:
      tags:
      - Chat
      summary: The caller's open message reminders
      description: Soonest first (at most 100).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Reminders
          content:
            application/json:
              schema:
                type: object
                properties:
                  reminders:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatMessageReminder"
                  has_more:
                    type: boolean
                    description: The caller has more than the 100 returned.
  "/chat/reminders/{id}":
    delete:
      tags:
      - Chat
      summary: Cancel a message reminder
      description: The caller's own reminder (or one they set). A write, not a delete-level
        act.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  cancelled:
                    type: boolean
                    enum:
                    - true
        '404':
          description: "`reminder_not_found`"
  "/chat/cards/{id}/decide":
    post:
      tags:
      - Chat
      summary: Press the button on a card in Needs you
      description: |
        A message from the Needs you service account (named Approvals until
        2026-10-22) carries `card`: something waiting on the caller.

        An approval (`kind: approval`, verdict `approve` / `reject`) is decided
        through the approvals registry as the caller — the same gates as the
        approvals page and POST /api/v1/home/waiting_on_you/decide: the queue's
        door, the record in the caller's queue right now, then the owning app's
        own action. Needs `write:chat` and `write:approvals`, plus the area's
        delete scope for a delete-level verdict (deny leave, reject a contract
        …). A decline the queue asks a reason for is refused
        (`reason_required`) — open the item (`card.row.deep_link`) to decline
        it with one.

        An Inbox ask's card (`kind: acknowledge` / `sign`; the verdict is the
        kind) is answered through the Inbox's own handler, as POST
        /api/v1/notifications/{id}/actions/{action_id}/perform would: a
        sign-off needs `write:approvals`, an acknowledgement
        `write:notifications`. One that asks the caller to affirm statements
        first is refused (`open_to_finish`, `card.act_needs_app`) — open the
        item to finish it there. A notice card (`kind: notice` — a safety
        incident at a location the caller manages) takes `seen`
        (`write:notifications`): the notice is read. A link card (`kind:
        link`) has no button: open `row.deep_link`.

        A card in a channel (`kind: open_shift`, from the OneMango account) is
        anyone's in the channel: `claim` claims the shift as the caller, with
        the claim's own rules (`write:shifts`) — `card.pending_approval` while
        a manager still decides.

        Every open room hears the change over `message:updated` with
        `update_kind "card"`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
        description: The card's id (`message.card.id`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - verdict
              properties:
                verdict:
                  type: string
                  enum:
                  - approve
                  - reject
                  - acknowledge
                  - sign
                  - seen
                  - claim
                  description: "`approve` / `reject` on an approval card; the card's
                    `kind` on an Inbox ask's card; `seen` on a notice card; `claim`
                    on an open shift."
      responses:
        '200':
          description: Decided, acknowledged or signed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  message:
                    type: string
                    description: The owning app's own sentence.
                  warnings:
                    type: array
                    items:
                      type: string
                  card:
                    "$ref": "#/components/schemas/ChatMessageCard"
        '403':
          description: "`insufficient_permissions` (a token without the scope the
            card asks — write:approvals, write:notifications for an acknowledgement
            or a notice, write:shifts for an open shift — or the delete scope) or
            `queue_unavailable`"
        '404':
          description: "`card_not_found` — not the caller's card, or its message is
            gone"
        '409':
          description: '`already_decided`, or `not_waiting` (decided elsewhere, or
            no longer waiting on the caller — the returned `card` now reads "Already
            handled")'
        '422':
          description: "`reason_required`, `open_to_finish` (an acknowledgement that
            asks for statements first), `invalid_verdict` or `decision_refused` (the
            app said no; `error.message` says why). The body carries `card`."
  "/chat/rooms/{id}/app_events":
    get:
      tags:
      - Chat
      summary: A channel's app updates
      description: |
        Which app updates post into this channel as cards, from the OneMango
        account: open shifts and coverage gaps at its location, team members
        recognised, and Celebrations — one card a day with the people the
        company celebrated that day (work anniversaries, welcomes, promotions,
        certificates, contest winners). An org channel starts with its
        source's (a store channel: open shifts and coverage gaps; a team
        channel: recognition); any other with none, and no channel takes
        Celebrations until its admins turn it on. Anyone in the channel may
        read it; `can_change` says whether the caller may change it (the
        channel's admins and the company's Chat admins).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The list
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatRoomAppEvents"
        '403':
          description: "`forbidden` — not in the channel"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`not_a_channel`"
    patch:
      tags:
      - Chat
      summary: Change a channel's app updates
      description: The whole list to keep — an empty one turns every update off. The
        channel's admins and the company's Chat admins only.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - events
              properties:
                events:
                  type: array
                  items:
                    type: string
                    enum:
                    - open_shifts
                    - coverage_gaps
                    - team_recognition
                    - celebrations
      responses:
        '200':
          description: The list as kept
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatRoomAppEvents"
        '403':
          description: "`forbidden` — not one of the channel's admins"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`not_a_channel`, `archived` (an archived channel's app updates
            cannot change) or `missing_events`"
  "/chat/rooms/{id}/webhooks":
    get:
      tags:
      - Chat
      summary: A channel's incoming webhooks
      description: |
        The channel's live incoming webhooks, newest first: a URL another tool
        posts into the channel with — Slack's incoming-webhook JSON, or an AWS
        SNS topic's HTTPS subscription. Each post comes from the company's
        OneMango account, headed with the webhook's name. The channel's admins
        and the company's Chat admins only (an archived channel's too, to
        revoke them). A webhook's URL is never listed: only its last four
        characters (`url_hint`). `enabled` is the company's "Incoming
        webhooks" switch (Chat settings); while it is off every URL answers 403
        and none can be added.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The list
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  enabled:
                    type: boolean
                  can_add:
                    type: boolean
                    description: false on an archived channel.
                  webhooks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ChatIncomingWebhook"
        '403':
          description: "`forbidden` — not one of the channel's admins"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`not_a_channel`"
    post:
      tags:
      - Chat
      summary: Add an incoming webhook to a channel
      description: |
        Answers the webhook and its `url` — the only time the URL is given: only
        a digest of its token is kept. Anyone with the URL can POST into the
        channel: Slack's message JSON (`{"text": …}`, attachments, blocks), or
        an AWS SNS subscription (it confirms itself). The channel is told it was
        added. Up to 20 per channel.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                  maxLength: 80
                  description: Heads every post the webhook makes.
      responses:
        '201':
          description: Added
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook:
                    "$ref": "#/components/schemas/ChatIncomingWebhook"
                  url:
                    type: string
                    description: https://hooks.<base>/chat/webhooks/<token> — shown
                      this once.
        '403':
          description: "`forbidden` — not one of the channel's admins; `incoming_webhooks_disabled`
            — the company's switch is off"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`not_a_channel`, `archived`, `invalid` (no name, or longer
            than 80) or `limit` (20 already)"
  "/chat/rooms/{id}/webhooks/{webhook_id}":
    patch:
      tags:
      - Chat
      summary: Rename an incoming webhook
      description: The new name heads its posts from now on; its URL does not change.
        The channel is told. Its admins and Chat admins may rename one, as they may
        revoke it — on an archived channel too, and while the company's incoming webhooks
        are switched off; a name of up to 80 characters. The same name again changes
        nothing (`renamed` false).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: webhook_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              properties:
                name:
                  type: string
                  maxLength: 80
                  description: Heads every post the webhook makes.
      responses:
        '200':
          description: Renamed (or already that name)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook:
                    "$ref": "#/components/schemas/ChatIncomingWebhook"
                  previous_name:
                    type: string
                    description: The name it had before this request.
                  renamed:
                    type: boolean
                    description: false when it already had that name — nothing changed
                      and the channel was not told.
        '403':
          description: "`forbidden` — not one of the channel's admins"
        '404':
          description: "`room_not_found` or `webhook_not_found` (none live with that
            id here)"
        '422':
          description: "`not_a_channel` or `invalid` (no name, or longer than 80)"
    delete:
      tags:
      - Chat
      summary: Revoke an incoming webhook
      description: Its URL answers 404 from now on, for good; the channel is told.
        Asks `destructive:chat` (a final act).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: webhook_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook:
                    "$ref": "#/components/schemas/ChatIncomingWebhook"
        '403':
          description: "`forbidden` — not one of the channel's admins"
        '404':
          description: "`room_not_found` or `webhook_not_found` (none live with that
            id here)"
        '422':
          description: "`not_a_channel`"
  "/chat/notification_mirror":
    get:
      tags:
      - Chat
      summary: '"Also send my notifications to Chat" — the caller''s setting'
      description: |
        Off until the person turns it on. While on, each new notification of a
        chosen kind is copied into their DM with the Notifications service
        account: its title, an excerpt and its link — never more than the
        notification carried. Copies are silent (no push, no notice): the DM's
        unread badge is all they add. Chat's own notifications are never
        copied. `available` is the notification settings page's own list of
        kinds, less Chat's.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The setting
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatNotificationMirror"
    put:
      tags:
      - Chat
      summary: Turn "Also send my notifications to Chat" on or off
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled:
                  type: boolean
                  description: Omitted keeps the switch where it is.
                categories:
                  type: array
                  items:
                    type: string
                  description: Keys from `available`. Omitted keeps the kinds chosen
                    before.
      responses:
        '200':
          description: The setting after the write
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ChatNotificationMirror"
        '409':
          description: '`retired` — the company''s "One interruption per item" is
            on, so notifications are not copied to Chat'
        '422':
          description: "`unknown_category` (a key not in `available`) or `no_categories`
            (on, with none chosen)"
  "/chat/rooms/{room_id}/messages/{message_id}/escalation":
    put:
      tags:
      - Chat
      summary: Set or change the backup on your own Important message
      description: |
        The sender only, on their Important message in a DM or group, until the
        backup has been told. Same rules as `escalate_to_user_id` /
        `escalate_after_minutes` on send. A backup set or changed here waits
        from now, not from when the message was sent; a wait that would end
        after the message is 14 days old is refused (`too_late`).
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - escalate_to_user_id
              - escalate_after_minutes
              properties:
                escalate_to_user_id:
                  type: integer
                escalate_after_minutes:
                  type: integer
                  enum:
                  - 60
                  - 120
                  - 240
                  - 1440
      responses:
        '200':
          description: The note as it now stands
          content:
            application/json:
              schema:
                type: object
                properties:
                  escalation:
                    "$ref": "#/components/schemas/ChatMessageEscalation"
        '404':
          description: "`message_not_found` — not the caller's message"
        '409':
          description: "`already_escalated`"
        '422':
          description: A refused backup or wait (the codes above), `escalation_needs_important`,
            or `too_late`
    delete:
      tags:
      - Chat
      summary: Take the backup off your own Important message
      description: Until the backup has been told. A write, not a delete-level act.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Taken off (`escalation` is null)
          content:
            application/json:
              schema:
                type: object
                properties:
                  escalation:
                    nullable: true
                    allOf:
                    - "$ref": "#/components/schemas/ChatMessageEscalation"
        '404':
          description: "`message_not_found`"
        '409':
          description: "`already_escalated`"
        '422':
          description: "`not_set`"
  "/chat/rooms/{id}/permissions":
    get:
      tags:
      - Chat
      summary: A room's permission settings and what the caller may do
      description: |
        The room's five stored codes, the caller's resolved capabilities, and
        whether the caller may change the settings — without GET
        /chat/rooms/{id}'s message window or its mark-read side effect. Any
        active member may read; a member who is not a room admin gets
        `editable: false` (show the dropdowns disabled, not hidden). The same
        codes ride on every serialized room.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Settings, capabilities and what the caller may change
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  room_type:
                    type: string
                    enum:
                    - direct
                    - group
                    - channel
                  settings:
                    type: object
                    description: The stored code for each setting, or its default
                      (`defaults`) where none is stored. "Admins" means room admins
                      and business admins.
                    properties:
                      other_member_invite:
                        type: string
                        enum:
                        - "Y"
                        - "N"
                        description: 'Who can add new members: Y any member, N admins
                          only.'
                      can_view_member:
                        type: string
                        enum:
                        - B
                        - G
                        description: 'Who can see the member list: B any member, G
                          admins only.'
                      im_permission:
                        type: string
                        enum:
                        - B
                        - G
                        - S
                        description: 'Who can send messages: B any member, G admins
                          only, S business admins only.'
                      important_msg_sender:
                        type: string
                        enum:
                        - B
                        - G
                        - S
                        description: 'Who can send important messages and request
                          read receipts: B any member, G admins only, S business admins
                          only.'
                      thread_replies:
                        type: string
                        enum:
                        - B
                        - G
                        description: 'Who can reply in threads: B any member, G admins
                          only. Read only in a channel.'
                  capabilities:
                    type: object
                    description: |
                      What the caller may do here, judged under the token. A
                      token without write:chat is offered no write (every key
                      but `can_see_members` is false); without destructive:chat,
                      `can_delete_for_everyone`, `can_archive`, `can_leave` and
                      `can_close_polls` are false.
                      `can_send_important_msg` and `can_request_read_receipt`
                      also need the company's Important messages / Read receipt
                      requests setting on and a DM, a group, or a private
                      channel of up to 50 people. `can_add_members` is false in
                      a DM.
                    properties:
                      can_add_members:
                        type: boolean
                      can_see_members:
                        type: boolean
                      can_send_im:
                        type: boolean
                      can_send_important_msg:
                        type: boolean
                      can_request_read_receipt:
                        type: boolean
                      can_reply_in_thread:
                        type: boolean
                        description: Channels only.
                      can_archive:
                        type: boolean
                      can_make_private:
                        type: boolean
                      can_make_public:
                        type: boolean
                      can_edit_details:
                        type: boolean
                      can_delete_for_everyone:
                        type: boolean
                      can_pin:
                        type: boolean
                      can_broadcast_mentions:
                        type: boolean
                      can_close_polls:
                        type: boolean
                      can_leave:
                        type: boolean
                      can_stop_org_sync:
                        type: boolean
                      can_manage_webhooks:
                        type: boolean
                  is_room_admin:
                    type: boolean
                  is_team_admin:
                    type: boolean
                    description: Same value as `is_room_admin` (the messenger's older
                      name).
                  editable:
                    type: boolean
                    description: The caller may save changes — a room admin, not in
                      a DM, on a token with write:chat. Gate the Save button on this.
                  locked:
                    type: object
                    description: |
                      Per setting, true when the caller may NOT change it: every
                      key while `editable` is false, and — for a room admin who
                      is not a business admin — any key holding `S`. Show that
                      dropdown disabled; the PATCH would answer 403.
                    additionalProperties:
                      type: boolean
                  allowed_values:
                    type: object
                    description: Every code each setting accepts, to build the dropdowns
                      from.
                    additionalProperties:
                      type: array
                      items:
                        type: string
                    example:
                      other_member_invite:
                      - "Y"
                      - "N"
                      can_view_member:
                      - B
                      - G
                      im_permission:
                      - B
                      - S
                      - G
                      important_msg_sender:
                      - B
                      - S
                      - G
                      thread_replies:
                      - B
                      - G
                  defaults:
                    type: object
                    description: The code a room holds when nothing is stored.
                    additionalProperties:
                      type: string
                    example:
                      other_member_invite: "Y"
                      can_view_member: B
                      im_permission: B
                      important_msg_sender: B
                      thread_replies: B
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a room member; or Chat app disabled"
        '404':
          description: "`room_not_found`"
    patch:
      tags:
      - Chat
      summary: Change a room's permission settings (room admins)
      description: |
        Room admins only. Send any of the five settings at the top level of
        the body; settings left out are unchanged, so one dropdown can be saved
        alone. Codes are exactly as listed (upper-case); one the setting does
        not take is refused and nothing is written. Setting a key to `S`, or
        changing one that holds `S`, needs a business admin — a room admin who
        is not one gets 403 `permission_tier_not_allowed` (resending the `S`
        already stored is allowed). Each setting that actually changes posts a
        room line ("Dana Lee changed who can send messages to: Domain admins
        only."). Not in a DM.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ChatRoomPermissionCodes"
      responses:
        '200':
          description: Saved — the same shape as GET, after the change
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  room_type:
                    type: string
                    enum:
                    - direct
                    - group
                    - channel
                  settings:
                    type: object
                    description: The stored code for each setting, or its default
                      (`defaults`) where none is stored. "Admins" means room admins
                      and business admins.
                    properties:
                      other_member_invite:
                        type: string
                        enum:
                        - "Y"
                        - "N"
                        description: 'Who can add new members: Y any member, N admins
                          only.'
                      can_view_member:
                        type: string
                        enum:
                        - B
                        - G
                        description: 'Who can see the member list: B any member, G
                          admins only.'
                      im_permission:
                        type: string
                        enum:
                        - B
                        - G
                        - S
                        description: 'Who can send messages: B any member, G admins
                          only, S business admins only.'
                      important_msg_sender:
                        type: string
                        enum:
                        - B
                        - G
                        - S
                        description: 'Who can send important messages and request
                          read receipts: B any member, G admins only, S business admins
                          only.'
                      thread_replies:
                        type: string
                        enum:
                        - B
                        - G
                        description: 'Who can reply in threads: B any member, G admins
                          only. Read only in a channel.'
                  capabilities:
                    type: object
                    description: |
                      What the caller may do here, judged under the token. A
                      token without write:chat is offered no write (every key
                      but `can_see_members` is false); without destructive:chat,
                      `can_delete_for_everyone`, `can_archive`, `can_leave` and
                      `can_close_polls` are false.
                      `can_send_important_msg` and `can_request_read_receipt`
                      also need the company's Important messages / Read receipt
                      requests setting on and a DM, a group, or a private
                      channel of up to 50 people. `can_add_members` is false in
                      a DM.
                    properties:
                      can_add_members:
                        type: boolean
                      can_see_members:
                        type: boolean
                      can_send_im:
                        type: boolean
                      can_send_important_msg:
                        type: boolean
                      can_request_read_receipt:
                        type: boolean
                      can_reply_in_thread:
                        type: boolean
                        description: Channels only.
                      can_archive:
                        type: boolean
                      can_make_private:
                        type: boolean
                      can_make_public:
                        type: boolean
                      can_edit_details:
                        type: boolean
                      can_delete_for_everyone:
                        type: boolean
                      can_pin:
                        type: boolean
                      can_broadcast_mentions:
                        type: boolean
                      can_close_polls:
                        type: boolean
                      can_leave:
                        type: boolean
                      can_stop_org_sync:
                        type: boolean
                      can_manage_webhooks:
                        type: boolean
                  is_room_admin:
                    type: boolean
                  is_team_admin:
                    type: boolean
                    description: Same value as `is_room_admin` (the messenger's older
                      name).
                  editable:
                    type: boolean
                    description: The caller may save changes — a room admin, not in
                      a DM, on a token with write:chat. Gate the Save button on this.
                  locked:
                    type: object
                    description: |
                      Per setting, true when the caller may NOT change it: every
                      key while `editable` is false, and — for a room admin who
                      is not a business admin — any key holding `S`. Show that
                      dropdown disabled; the PATCH would answer 403.
                    additionalProperties:
                      type: boolean
                  allowed_values:
                    type: object
                    description: Every code each setting accepts, to build the dropdowns
                      from.
                    additionalProperties:
                      type: array
                      items:
                        type: string
                    example:
                      other_member_invite:
                      - "Y"
                      - "N"
                      can_view_member:
                      - B
                      - G
                      im_permission:
                      - B
                      - S
                      - G
                      important_msg_sender:
                      - B
                      - S
                      - G
                      thread_replies:
                      - B
                      - G
                  defaults:
                    type: object
                    description: The code a room holds when nothing is stored.
                    additionalProperties:
                      type: string
                    example:
                      other_member_invite: "Y"
                      can_view_member: B
                      im_permission: B
                      important_msg_sender: B
                      thread_replies: B
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a room member, or not a room admin; `permission_tier_not_allowed`
            — only a business admin may set or clear `S`; or Chat app disabled / no
            write:chat"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`direct_room` (a DM has no settings — answered only to a member:
            a non-member gets the 403 `forbidden` first), `nothing_to_update` (none
            of the five settings sent), or `invalid_permission_setting` (a code that
            setting does not take)"
    put:
      tags:
      - Chat
      summary: Change a room's permission settings (PUT)
      description: Same as PATCH on this path — the same body, rules and responses.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ChatRoomPermissionCodes"
      responses:
        '200':
          description: Saved — the same shape as GET, after the change
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  room_type:
                    type: string
                    enum:
                    - direct
                    - group
                    - channel
                  settings:
                    type: object
                    description: The stored code for each setting, or its default
                      (`defaults`) where none is stored. "Admins" means room admins
                      and business admins.
                    properties:
                      other_member_invite:
                        type: string
                        enum:
                        - "Y"
                        - "N"
                        description: 'Who can add new members: Y any member, N admins
                          only.'
                      can_view_member:
                        type: string
                        enum:
                        - B
                        - G
                        description: 'Who can see the member list: B any member, G
                          admins only.'
                      im_permission:
                        type: string
                        enum:
                        - B
                        - G
                        - S
                        description: 'Who can send messages: B any member, G admins
                          only, S business admins only.'
                      important_msg_sender:
                        type: string
                        enum:
                        - B
                        - G
                        - S
                        description: 'Who can send important messages and request
                          read receipts: B any member, G admins only, S business admins
                          only.'
                      thread_replies:
                        type: string
                        enum:
                        - B
                        - G
                        description: 'Who can reply in threads: B any member, G admins
                          only. Read only in a channel.'
                  capabilities:
                    type: object
                    description: |
                      What the caller may do here, judged under the token. A
                      token without write:chat is offered no write (every key
                      but `can_see_members` is false); without destructive:chat,
                      `can_delete_for_everyone`, `can_archive`, `can_leave` and
                      `can_close_polls` are false.
                      `can_send_important_msg` and `can_request_read_receipt`
                      also need the company's Important messages / Read receipt
                      requests setting on and a DM, a group, or a private
                      channel of up to 50 people. `can_add_members` is false in
                      a DM.
                    properties:
                      can_add_members:
                        type: boolean
                      can_see_members:
                        type: boolean
                      can_send_im:
                        type: boolean
                      can_send_important_msg:
                        type: boolean
                      can_request_read_receipt:
                        type: boolean
                      can_reply_in_thread:
                        type: boolean
                        description: Channels only.
                      can_archive:
                        type: boolean
                      can_make_private:
                        type: boolean
                      can_make_public:
                        type: boolean
                      can_edit_details:
                        type: boolean
                      can_delete_for_everyone:
                        type: boolean
                      can_pin:
                        type: boolean
                      can_broadcast_mentions:
                        type: boolean
                      can_close_polls:
                        type: boolean
                      can_leave:
                        type: boolean
                      can_stop_org_sync:
                        type: boolean
                      can_manage_webhooks:
                        type: boolean
                  is_room_admin:
                    type: boolean
                  is_team_admin:
                    type: boolean
                    description: Same value as `is_room_admin` (the messenger's older
                      name).
                  editable:
                    type: boolean
                    description: The caller may save changes — a room admin, not in
                      a DM, on a token with write:chat. Gate the Save button on this.
                  locked:
                    type: object
                    description: |
                      Per setting, true when the caller may NOT change it: every
                      key while `editable` is false, and — for a room admin who
                      is not a business admin — any key holding `S`. Show that
                      dropdown disabled; the PATCH would answer 403.
                    additionalProperties:
                      type: boolean
                  allowed_values:
                    type: object
                    description: Every code each setting accepts, to build the dropdowns
                      from.
                    additionalProperties:
                      type: array
                      items:
                        type: string
                    example:
                      other_member_invite:
                      - "Y"
                      - "N"
                      can_view_member:
                      - B
                      - G
                      im_permission:
                      - B
                      - S
                      - G
                      important_msg_sender:
                      - B
                      - S
                      - G
                      thread_replies:
                      - B
                      - G
                  defaults:
                    type: object
                    description: The code a room holds when nothing is stored.
                    additionalProperties:
                      type: string
                    example:
                      other_member_invite: "Y"
                      can_view_member: B
                      im_permission: B
                      important_msg_sender: B
                      thread_replies: B
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a room member, or not a room admin; `permission_tier_not_allowed`
            — only a business admin may set or clear `S`; or Chat app disabled / no
            write:chat"
        '404':
          description: "`room_not_found`"
        '422':
          description: "`direct_room` (a DM has no settings — answered only to a member:
            a non-member gets the 403 `forbidden` first), `nothing_to_update` (none
            of the five settings sent), or `invalid_permission_setting` (a code that
            setting does not take)"
  "/chat/rooms/{room_id}/ask_ai/summarize":
    post:
      tags:
      - Chat
      summary: Ask the assistant to summarize a thread or the room's recent conversation
      description: |
        With `thread_root_id`, summarizes that thread; without it, the room's
        recent conversation — the last 24 hours, at most 100 messages, unless
        `window` says otherwise (`window` is ignored with `thread_root_id`).
        Answers 202 at once; the summary is posted to the room later as a
        normal Ask AI message whose `ai_affordances.provenance` is `summary` (a
        channel thread's summary is a reply in that thread). Because it is a
        post every member reads, the caller must be allowed to post here — in
        a channel thread, to reply in it. At most 5 summaries per room per
        hour, thread and room summaries together. Needs the Chat settings
        `ask_ai_enabled` and `ask_ai_summaries_enabled`, and Platform Standard
        or above.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                thread_root_id:
                  type: integer
                  description: A live top-level message in this room. Omit to summarize
                    the room.
                window:
                  type: string
                  pattern: "^(messages|hours):[1-9][0-9]{0,2}$"
                  example: hours:48
                  description: 'Room scope only: `messages:<n>` (n up to 200) or `hours:<h>`
                    (h up to 168; at most 200 messages). Out of bounds is refused,
                    never clamped.'
      responses:
        '202':
          description: Queued — the summary arrives as a room message
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    enum:
                    - true
                  status:
                    type: string
                    enum:
                    - queued
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: "`feature_disabled` (either setting off); `forbidden` — not
            a room member; `im_not_allowed` — may not post (or reply in this channel
            thread); `information_barrier` in a DM the company's information barriers
            keep apart; or Chat app disabled"
        '404':
          description: "`room_not_found` / `thread_root_not_found` (not in this room,
            deleted, or a reply rather than a root)"
        '422':
          description: "`invalid_window`"
        '429':
          description: "`rate_limited` (5 per room per hour) / `budget_exhausted`
            (the company's monthly Ask AI budget is spent)"
        '503':
          description: "`entitlement_unavailable` — the plan could not be checked
            just now; try again"
  "/chat/rooms/{room_id}/ask_ai/suggest_replies":
    post:
      tags:
      - Chat
      summary: Smart reply suggestions for one message (private to the caller)
      description: |
        Up to three short replies the caller could send to `message_id`,
        generated now and returned to the caller alone — nothing is posted or
        stored. A tapped suggestion should only fill the composer, never send.
        A message that gets no suggestions — the caller's own, the
        assistant's, a room notice, one with no text, or a read-receipt request
        the caller has not acknowledged — answers 200 with an empty list and
        costs nothing from the hourly allowance. A model outage or malformed completion
        answers 503 service_unavailable and refunds the hourly slot. Valid [] answers 200.
        60 per person per hour. Needs the Chat settings `ask_ai_enabled` and
        `ask_ai_smart_replies_enabled`, and Platform Standard or above. A POST,
        so a token needs write:chat though nothing is written.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message_id
              properties:
                message_id:
                  type: integer
                  description: A live message in this room.
                context_scope:
                  type: string
                  enum:
                  - room
                  - thread
                  default: room
                thread_root_id:
                  type: integer
                  description: Required for thread scope; must contain message_id.
      responses:
        '200':
          description: Zero to three suggestions
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    maxItems: 3
                    items:
                      type: string
                      maxLength: 200
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: "`feature_disabled` (either setting off); `forbidden` — not
            a room member; or Chat app disabled / no write:chat"
        '404':
          description: "`room_not_found` / `message_not_found` (not in this room,
            or deleted)"
        '422':
          description: "`invalid_request` — no `message_id`"
        '429':
          description: "`rate_limited` (60 per person per hour) / `budget_exhausted`
            (the company's monthly Ask AI budget is spent)"
        '503':
          description: "`service_unavailable` — model unavailable or invalid output;
            `entitlement_unavailable` — plan check unavailable"
  "/chat/rooms/{room_id}/messages/{message_id}/remind":
    post:
      tags:
      - Chat
      summary: Remind the people who have not acknowledged your message
      description: |
        The sender only, on their Important or read-receipt message: a bell
        row and a push (no email) to everyone still to acknowledge it, under
        their own notification settings (a muted room, "Nothing" for the room
        or Do Not Disturb drops the push, not the bell row). People whose read
        status a works council withholds are left out. Only for a message
        recorded as sent by a manager or room admin, never in a one-to-one
        chat, and at most once in 24 hours (automatic reminders count). GET
        …/acknowledgements says in advance (`can_remind`,
        `remind_unavailable_reason`). (`remind`, not `reminder`: …/reminder
        sets a reminder for the caller.)
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Reminder sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  message_id:
                    type: integer
                  recipients:
                    type: integer
                    description: People the reminder reached.
                  reminder_count:
                    type: integer
                    description: Reminders sent for this message so far
                    automatic and manual.:
                  last_reminded_at:
                    type: string
                    format: date-time
        '401':
          description: Unauthorized
        '403':
          description: "`forbidden` — not a room member, or not the sender; or Chat
            app disabled"
        '404':
          description: "`room_not_found` / `message_not_found` (or deleted)"
        '422':
          description: |
            `direct_chat` (a one-to-one chat); `not_remindable` (a message
            that asks for no acknowledgement, a deleted one, a room notice, one
            not recorded as a manager's or room admin's, or a sender no longer
            active here); `feature_off` (Important messages / Read receipt
            requests turned off); `too_soon` (reminded in the last 24 hours);
            `nobody_pending`; `nobody_reachable` (everyone pending has these
            notices off); `not_delivered` (no reminder landed — try again).
            Nothing is counted on any of them.
  "/chat/rooms/{room_id}/messages/{message_id}/translate":
    post:
      tags:
      - Chat
      summary: Translate a message for the caller
      description: |
        Translates the message's text into `lang`, or into the caller's own
        content language (their profile language, else their location's, else
        the company's default, else English). Cached per message and language,
        so other readers in that language reuse it. A message already in the
        target language answers `translated: false` with no `body`. Not a
        read-receipt request the caller has not acknowledged. 30 fresh
        translations per person per hour — one answered from the cache (asked
        again, or already translated for another reader in that language) is
        not counted and is served even past the limit. Off when the Chat
        setting `message_translation_enabled` is off.
      security:
      - BearerAuth: []
      parameters:
      - name: room_id
        in: path
        required: true
        schema:
          type: integer
      - name: message_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                lang:
                  type: string
                  example: es
                  description: A language the translator supports, in any case; a
                    region variant folds to its base language (en-US → en) unless
                    it is supported itself (pt-BR, zh-TW). Omit for the caller's own.
      responses:
        '200':
          description: Translated, or already in that language
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  message_id:
                    type: integer
                  language:
                    type: string
                    description: The target language used.
                  provider:
                    type: string
                    description: Omitted when unknown.
                  translated:
                    type: boolean
                    description: False when the message was already in `language`.
                  source_language:
                    type: string
                    description: The detected language of the message. Omitted when
                      unknown.
                  body:
                    type: string
                    description: The translated text — present only when `translated`
                      is true.
                  message:
                    type: string
                    description: 'Present only when `translated` is false: the sentence
                      saying why, in the caller''s language — "This message is already
                      in your language." (Nothing to translate and translation being
                      off are refusals: `nothing_to_translate` 422 and `feature_disabled`
                      403, each with its sentence in `error.message`.)'
        '401':
          description: Unauthorized
        '403':
          description: "`feature_disabled` (answered before the room is looked up);
            `forbidden` — not a room member; or Chat app disabled"
        '404':
          description: "`room_not_found` / `message_not_found` (or deleted)"
        '422':
          description: "`nothing_to_translate` (no text), `ack_required` (an unacknowledged
            read-receipt request), `unsupported_language`, or `translation_failed`
            (the provider failed — this one also carries `ok: false`)"
        '429':
          description: "`rate_limited` (30 fresh translations per person per hour)"
  "/chat/ask_ai/actions/{id}/confirm":
    post:
      tags:
      - Chat
      summary: Confirm an action the assistant proposed in a room
      description: |
        `id` is `ai_affordances.action_request.id` on the assistant's message.
        Only someone in its `permitted_confirmer_ids` may resolve it — the
        person who asked, or for a proactive suggestion the people it is
        about. Confirming runs the paused action now and posts the assistant's
        outcome to the room before answering: a 200 says the action was
        confirmed, not that it succeeded — that is in the assistant's message.
        Refused, and left pending, when that assistant is no longer on for the
        caller or the company's work-context policy closes its app to them now.
        Every resolution is broadcast as `ask_ai:action_resolved` on
        `private-room-<id>` ({ room_id, message_id, action_request }). An
        action not resolved within 15 minutes expires. Needs the Chat setting
        `ask_ai_actions_enabled` and Platform Standard or above.

        The confirmed tools run under the request's token: a tool its scopes
        do not grant is refused (the assistant's outcome message says so).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Confirmed — the terminal action request
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_request:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      kind:
                        type: string
                        enum:
                        - create_task
                        - create_calendar_event
                        - schedule_huddle
                        - create_ticket
                        - generic
                        description: Render an unknown value as generic.
                      summary:
                        type: string
                        example: Create task "Fix login bug" assigned to Priya
                      invoker_user_id:
                        type: integer
                      permitted_confirmer_ids:
                        type: array
                        items:
                          type: integer
                      status:
                        type: string
                        enum:
                        - pending
                        - confirmed
                        - cancelled
                        - expired
                      resolved_by_user_id:
                        type: integer
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: "`feature_disabled`; `not_permitted` — not in `permitted_confirmer_ids`;
            `forbidden` — that assistant is no longer on for the caller, or the work-context
            policy closes its app now (the body also carries `action_request`); or
            Chat app disabled"
        '404':
          description: "`action_not_found`"
        '409':
          description: "`already_resolved`"
          content:
            application/json:
              schema:
                type: object
                description: The house error envelope plus the action as it now stands,
                  so the row can be updated in place.
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  action_request:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      kind:
                        type: string
                        enum:
                        - create_task
                        - create_calendar_event
                        - schedule_huddle
                        - create_ticket
                        - generic
                        description: Render an unknown value as generic.
                      summary:
                        type: string
                        example: Create task "Fix login bug" assigned to Priya
                      invoker_user_id:
                        type: integer
                      permitted_confirmer_ids:
                        type: array
                        items:
                          type: integer
                      status:
                        type: string
                        enum:
                        - pending
                        - confirmed
                        - cancelled
                        - expired
                      resolved_by_user_id:
                        type: integer
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
        '410':
          description: "`expired` — including a pending action whose paused turn can
            no longer be resumed"
          content:
            application/json:
              schema:
                type: object
                description: The house error envelope plus the action as it now stands,
                  so the row can be updated in place.
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  action_request:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      kind:
                        type: string
                        enum:
                        - create_task
                        - create_calendar_event
                        - schedule_huddle
                        - create_ticket
                        - generic
                        description: Render an unknown value as generic.
                      summary:
                        type: string
                        example: Create task "Fix login bug" assigned to Priya
                      invoker_user_id:
                        type: integer
                      permitted_confirmer_ids:
                        type: array
                        items:
                          type: integer
                      status:
                        type: string
                        enum:
                        - pending
                        - confirmed
                        - cancelled
                        - expired
                      resolved_by_user_id:
                        type: integer
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
        '503':
          description: "`service_unavailable` — the work-context policy could not
            be checked (the body also carries `action_request`); `entitlement_unavailable`
            — the plan could not be checked. Try again."
  "/chat/ask_ai/actions/{id}/cancel":
    post:
      tags:
      - Chat
      summary: Decline an action the assistant proposed in a room
      description: |
        Same rule as confirm: `permitted_confirmer_ids` only. Marks the action
        cancelled and drops the paused turn — nothing runs. Broadcast as
        `ask_ai:action_resolved`. Needs the Chat setting
        `ask_ai_actions_enabled` and Platform Standard or above.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Cancelled — the terminal action request
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_request:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      kind:
                        type: string
                        enum:
                        - create_task
                        - create_calendar_event
                        - schedule_huddle
                        - create_ticket
                        - generic
                        description: Render an unknown value as generic.
                      summary:
                        type: string
                        example: Create task "Fix login bug" assigned to Priya
                      invoker_user_id:
                        type: integer
                      permitted_confirmer_ids:
                        type: array
                        items:
                          type: integer
                      status:
                        type: string
                        enum:
                        - pending
                        - confirmed
                        - cancelled
                        - expired
                      resolved_by_user_id:
                        type: integer
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: "`feature_disabled`; `not_permitted` — not in `permitted_confirmer_ids`;
            or Chat app disabled"
        '404':
          description: "`action_not_found`"
        '409':
          description: "`already_resolved`"
          content:
            application/json:
              schema:
                type: object
                description: The house error envelope plus the action as it now stands,
                  so the row can be updated in place.
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  action_request:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      kind:
                        type: string
                        enum:
                        - create_task
                        - create_calendar_event
                        - schedule_huddle
                        - create_ticket
                        - generic
                        description: Render an unknown value as generic.
                      summary:
                        type: string
                        example: Create task "Fix login bug" assigned to Priya
                      invoker_user_id:
                        type: integer
                      permitted_confirmer_ids:
                        type: array
                        items:
                          type: integer
                      status:
                        type: string
                        enum:
                        - pending
                        - confirmed
                        - cancelled
                        - expired
                      resolved_by_user_id:
                        type: integer
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
        '410':
          description: "`expired`"
          content:
            application/json:
              schema:
                type: object
                description: The house error envelope plus the action as it now stands,
                  so the row can be updated in place.
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                      message:
                        type: string
                  action_request:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      kind:
                        type: string
                        enum:
                        - create_task
                        - create_calendar_event
                        - schedule_huddle
                        - create_ticket
                        - generic
                        description: Render an unknown value as generic.
                      summary:
                        type: string
                        example: Create task "Fix login bug" assigned to Priya
                      invoker_user_id:
                        type: integer
                      permitted_confirmer_ids:
                        type: array
                        items:
                          type: integer
                      status:
                        type: string
                        enum:
                        - pending
                        - confirmed
                        - cancelled
                        - expired
                      resolved_by_user_id:
                        type: integer
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
        '503':
          description: "`entitlement_unavailable` — the plan could not be checked
            just now; try again"
  "/chat/ask_ai/search":
    post:
      tags:
      - Chat
      summary: Ask a question of your own chat conversations
      description: |
        Answers a question from the rooms the caller is in, and only those,
        with the messages it rests on — now, to the caller alone; the question
        is not saved to their Ask AI history. `answer` is null with no
        citations when nothing in their conversations answers it. An `[n]`
        marker in `answer` points at `citations[n-1]`. The question passes the
        company's content moderation first. 30 per person per hour. Needs the
        Chat settings `ask_ai_enabled`, `ask_ai_retrieval_enabled` and
        `semantic_search_enabled`, and Platform Standard or above. A POST, so a
        token needs write:chat though nothing is written.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - query
              properties:
                query:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: The answer and its sources, or null when nothing grounds
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    type: string
                    nullable: true
                  citations:
                    type: array
                    items:
                      type: object
                      properties:
                        room_id:
                          type: integer
                        message_id:
                          type: integer
                        text:
                          type: string
                          maxLength: 200
                          description: An excerpt of the message.
        '401':
          description: Unauthorized
        '402':
          description: Platform tier below Standard (error.code "upgrade_required")
        '403':
          description: "`feature_disabled` (any of the three settings off); or Chat
            app disabled / no write:chat"
        '422':
          description: |
            `invalid_request` (blank query) / `query_too_long` (over 500
            characters); or refused by content moderation, in a flat body —
            `{ "error": "<sentence>", "blocked": true, "reason": "…" }`.
        '429':
          description: "`rate_limited` (30 per person per hour) / `budget_exhausted`
            (the company's monthly Ask AI budget is spent)"
        '503':
          description: "`entitlement_unavailable` — the plan could not be checked
            just now; try again"
  "/chat/presence/offline":
    post:
      tags:
      - Chat
      summary: Mark the caller offline now
      description: |
        The sign-out / app-quit signal: drops the caller's presence so others
        see them offline on their next GET /chat/presence/online, instead of
        after the 5-minute heartbeat expiry. The next heartbeat marks them
        online again. Idempotent — safe to send on both sign-out and window
        close.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Offline
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    enum:
                    - true
                  state:
                    type: string
                    enum:
                    - offline
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/reactions":
    get:
      tags:
      - Chat
      summary: The quick reaction set
      description: |
        The reactions to draw the picker from, instead of a hard-coded list:
        the company's reaction palette (an admin's choice in Communications
        settings, used in every app; the whole catalog when none was chosen).
        The same list is `reactions` in GET /chat/config. POST …/reactions also
        takes any single emoji (`reactions_accept_any_emoji`).
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The palette, in display order
          content:
            application/json:
              schema:
                type: object
                properties:
                  reactions:
                    type: array
                    items:
                      type: object
                      properties:
                        emoji:
                          type: string
                          example: "\U0001F44D"
                        key:
                          type: string
                          nullable: true
                          example: like
                          description: The reaction's catalog key; null for a glyph
                            outside the catalog.
                        label:
                          type: string
                          example: Like
                          description: For tooltips and screen readers.
                        icon_url:
                          type: string
                          nullable: true
                          example: https://acme.workforce.mangoapps.com/assets/reactions/like-1a2b3c.svg
                          description: Absolute URL of the drawn icon the web shows
                            for this reaction. null ⇒ draw the emoji glyph.
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
  "/chat/deleted_rooms":
    get:
      tags:
      - Chat
      summary: Conversations the caller may restore after "Delete for everyone"
      description: 'The API twin of the web Chat › Recently deleted page, and the
        list behind `POST /chat/deleted_rooms/{id}/restore`. A business admin or Chat
        app admin — with a token that may act as one (`write:chat` / `read:chat`,
        not an `own_chat` grant) — sees every conversation of the company that was
        deleted for everyone; anyone else sees only the ones they deleted (`scope`
        says which). Newest deleted first, 25 a page. A conversation can be restored
        until `restorable_until` (`restore_window_days` after it was deleted, a fixed
        length: the same instant for every viewer and for the nightly purge, across
        a clock change too). One on a legal hold is never purged, so it stays restorable
        after that. Holds are told to business (company) admins only, as the room
        payload''s `on_legal_hold` is: for them `legal_hold` is true and `restorable_until`
        null while a hold keeps it. Anyone else — a Chat app admin who is not a company
        admin included — gets `legal_hold: null` and the window''s end, so a held
        row past its window still has `can_restore: true` after `restorable_until`:
        show it as due any day ("Any day now"), with Restore still offered, as the
        web page does. Branch on `can_restore`, never on the clock.'
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      responses:
        '200':
          description: One page of deleted conversations
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted_rooms:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                          nullable: true
                        display_name:
                          type: string
                          description: 'The title to show, never null, as the web''s
                            Recently deleted page names it: `name` when set; else,
                            where the caller may see who was in it (as `participants`),
                            a group''s first three people and how many more ("Ann,
                            Bo, Cy and 2 more") or a direct message''s two ("Ann &
                            Bo"); else the words every list uses — "Untitled group",
                            "Untitled channel" or "Direct message" (the room payload''s
                            `display_name` fallback). In the caller''s language.'
                        room_type:
                          type: string
                          enum:
                          - group
                          - channel
                        is_public:
                          type: boolean
                          description: 'The room payload''s `is_public`: true for
                            a public channel (listed in Browse Channels, anyone may
                            join), false for a private channel and every group. Draw
                            a private channel with its lock, as the web''s Recently
                            deleted page does.'
                        member_count:
                          type: integer
                          nullable: true
                          description: Members still in it. Null where the caller
                            may not see who is in it (its "Who can see members?" is
                            admins only and they are not a room admin there) — never
                            on the company list.
                        participants:
                          type: array
                          maxItems: 3
                          description: The first three people still in it, in roster
                            order — name an unnamed conversation by them ("Ann, Bo,
                            Cy and 2 more" with `member_count`), as the web page does.
                            Empty where `member_count` is null.
                          items:
                            "$ref": "#/components/schemas/ChatUserSummary"
                        discarded_at:
                          type: string
                          format: date-time
                        discarded_by:
                          type: object
                          nullable: true
                          properties:
                            id:
                              type: integer
                            name:
                              type: string
                              nullable: true
                        legal_hold:
                          type: boolean
                          nullable: true
                          description: True while a legal hold keeps it from being
                            purged. Business (company) admins only; null for everyone
                            else, who are not told a hold exists.
                        restorable_until:
                          type: string
                          format: date-time
                          nullable: true
                          description: The end of the restore window. Null while a
                            legal hold keeps it — told to company admins only; anyone
                            else always gets the window's end.
                        can_restore:
                          type: boolean
                          description: 'POST /chat/deleted_rooms/{id}/restore would
                            restore it now: false once it is past its window and no
                            hold keeps it (it is being permanently deleted — show
                            "Can''t be restored"), and for a token without write:chat
                            — or, on the company list, for someone else''s deletion
                            when the token holds only write:own_chat (restore then
                            finds only the caller''s own). Can be true after `restorable_until`
                            (a hold the caller is not told of keeps it).'
                  scope:
                    type: string
                    enum:
                    - company
                    - own
                    description: "`company` — every conversation the company deleted
                      for everyone (a business or Chat app admin, under a token that
                      may act as one); `own` — only the ones the caller deleted. Say
                      which in the list's subtitle."
                  restore_window_days:
                    type: integer
                    example: 30
                    description: How long a deleted conversation stays restorable
                      — the constant the purge and the restore refusal count from.
                  page:
                    type: integer
                  has_more:
                    type: boolean
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
          or token lacks read:chat:
  "/chat/deleted_rooms/{id}/restore":
    post:
      tags:
      - Chat
      summary: Restore a conversation deleted for everyone
      description: The undo of `DELETE /chat/rooms/{id}?for_all=true`, on the same
        rules as `GET /chat/deleted_rooms` (a Chat admin any of the company's, anyone
        else their own). The conversation comes back with its full history and members;
        its members get a `room:restored` event and a bell notice. A channel whose
        name another live channel took meanwhile comes back named `<name>-<id>`. Refused
        once its restore window has passed and no legal hold keeps it (it is being
        permanently deleted) — the row's `can_restore` says so beforehand.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Restored
          content:
            application/json:
              schema:
                type: object
                properties:
                  room_id:
                    type: integer
                  restored:
                    type: boolean
                    enum:
                    - true
        '401':
          description: Unauthorized
        '403':
          description: Chat app disabled
          or token lacks write:chat:
        '404':
          description: "`room_not_found` — not in the caller's Recently deleted (never
            deleted, already restored, purged, or someone else's deletion and the
            caller is not a Chat admin)"
        '422':
          description: "`restore_window_passed`, or `not_restored` (it could not be
            saved; still in Recently deleted — try again)"
  "/rfp_desk/answers":
    get:
      tags:
      - RFP Desk
      summary: List answer-library entries
      description: |
        Approved, reusable answers to RFP questions. Requires the RFP Desk contributor (or admin) grant — the same gate as the Answer Library tab.
        Filter by `category`, `status` (`active`, `archived`, `stale` — not marked reviewed (or, if never reviewed, its question or answer text not changed) within the workspace's staleness window, 12 months by default) and a free-text `q` over question and answer text.
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        schema:
          type: string
      - name: category
        in: query
        schema:
          type: string
          enum:
          - technical
          - compliance
          - commercial
          - delivery
          - other
      - name: status
        in: query
        schema:
          type: string
          enum:
          - active
          - archived
          - stale
      - name: mine
        in: query
        description: Only answers the caller is named owner of
        schema:
          type: boolean
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Number of records per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated answers
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/RfpAnswer"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
    post:
      tags:
      - RFP Desk
      summary: Add an answer to the library
      description: Creates an approved answer. Questions are unique per workspace
        (case-insensitive); a duplicate returns 422.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RfpAnswerInput"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/answers/{id}":
    get:
      tags:
      - RFP Desk
      summary: Get one answer
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The answer
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
    patch:
      tags:
      - RFP Desk
      summary: Update an answer
      description: |
        Any subset of the input fields. Setting `active` to true reactivates an archived answer.

        Setting `active` to false on a live answer retires it as
        `POST /rfp_desk/answers/{id}/archive` does, so that update is
        delete-level (owner decision 2026-10-04, #6721): on top of
        `write:rfp_response` it requires the `destructive:rfp_response` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RfpAnswerInput"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/answers/{id}/review":
    post:
      tags:
      - RFP Desk
      summary: Mark an answer as reviewed
      description: Confirms the answer is still true without editing it — restarts
        its staleness window (12 months unless the tenant set another).
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Reviewed
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
  "/rfp_desk/answers/{id}/archive":
    post:
      tags:
      - RFP Desk
      summary: Retire an answer
      description: |
        Sets `active` to false. The answer stops being offered to drafters; sections that already reused it keep their provenance. There is no delete over the API.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:rfp_response` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer:
                    "$ref": "#/components/schemas/RfpAnswer"
  "/rfp_desk/responses":
    get:
      tags:
      - RFP Desk
      summary: List RFP responses
      description: 'Responses the caller could open in the browser: the participant
        floor (responsible, collaborator, section owner, creator) plus the workspace''s
        who-can-view-all-responses setting. Filter by `status`.

        '
      security:
      - BearerAuth: []
      parameters:
      - name: status
        in: query
        schema:
          type: string
          enum:
          - draft
          - on_hold
          - in_review
          - approved
          - submitted
          - declined
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Number of records per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated responses
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/RfpResponseSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/rfp_desk/responses/{id}":
    get:
      tags:
      - RFP Desk
      summary: Get a response with its sections
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: only_gaps
        in: query
        description: Return only sections still needing input
        schema:
          type: boolean
      responses:
        '200':
          description: The response and its sections in order
          content:
            application/json:
              schema:
                type: object
                properties:
                  response:
                    "$ref": "#/components/schemas/RfpResponse"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/responses/{id}/sections/{section_id}":
    patch:
      tags:
      - RFP Desk
      summary: Answer a section
      description: 'Writes the section''s draft as a human answer — the same rules
        as the `resolve_section` agent tool: an approved or submitted response refuses
        (409), a draft still carrying a `[NEEDS INPUT: …]` placeholder leaves the
        gap open, a changed draft is marked as human-grounded and its review mark
        is cleared, and the routed task (if any) closes when the gap closes. `save_to_library`
        also banks the answer as a library entry (contributors only; skipped for narrative
        sections and open gaps).

        '
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: section_id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - draft
              properties:
                draft:
                  type: string
                save_to_library:
                  type: boolean
                  default: false
                base_draft_version:
                  type: string
                  description: Optional. The section's `draft_version` (a digest of
                    the answer's words, returned on every section) the caller read
                    the answer on. When the stored answer has changed since and this
                    call would replace it with different words, nothing is written
                    and the call answers 409 `draft_conflict`, whose `details` carry
                    the saved `draft` and its `draft_version`. Omit it to write over
                    whatever is stored.
      responses:
        '200':
          description: The updated section
          content:
            application/json:
              schema:
                type: object
                properties:
                  section:
                    "$ref": "#/components/schemas/RfpSection"
                  remaining_gaps:
                    type: integer
                  still_needs_input:
                    type: boolean
                  saved_to_library:
                    type: boolean
                  library_note:
                    type: string
                    nullable: true
                  withdrawn_sign_offs:
                    type: array
                    items:
                      type: string
                    description: Specialist sign-offs (e.g. "Legal") this change withdrew;
                      they have to be given again
        '409':
          description: Either `response_locked` (the response is approved or submitted
            — reopen it in RFP Desk first) or `draft_conflict` (the answer was saved
            by someone else since `base_draft_version`; `error.details` holds `draft`
            and `draft_version` as saved).
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/responses/{id}/transition":
    post:
      tags:
      - RFP Desk
      summary: Move a response to another status
      description: |
        Same transition map and approval gate as the web app. Approving, submitting, declining or reopening a locked response needs the responsible person or an app admin (403 otherwise); anyone may while nobody is responsible. Approval needs every section answered and reviewed, the specialist sign-off on every Legal, Security, Commercial or HR section, and every required deliverable ticked (422 with the reason otherwise). Declining withdraws routed questions from people's task lists.

        Declining a bid (`status` `declined`) is delete-level (owner decision
        2026-10-04, #6721): requires the `destructive:rfp_response` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - draft
                  - on_hold
                  - in_review
                  - approved
                  - submitted
                  - declined
      responses:
        '200':
          description: The response after the move
          content:
            application/json:
              schema:
                type: object
                properties:
                  response:
                    "$ref": "#/components/schemas/RfpResponseSummary"
                  withdrawn_tasks:
                    type: integer
                  questions_to_resend:
                    type: integer
                    description: 'On a reopen of a declined bid (declined to draft):
                      how many routed questions the decline withdrew that are still
                      unanswered and not back in anyone''s task list — reopening does
                      not restore them, so send each to its expert again. 0 on every
                      other move.'
                  cleared_outcome:
                    type: string
                    nullable: true
                    enum:
                    - won
                    - lost
                    -
                    description: The Won/Lost outcome a reopen (submitted to draft)
                      cleared, or null. A reopen of a bid whose outcome comes from
                      its Mango GTM proposal is refused with 409 outcome_owned_by_proposal.
        '403':
          description: Insufficient permissions to access this resource
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
        '409':
          description: Request conflicts with current state
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
        '422':
          description: Request cannot be processed
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/rfp_desk/assessments":
    get:
      tags:
      - RFP Desk
      summary: List bid/no-bid assessments
      description: Completed rfp_fit assessments the caller may read — all of them
        for an RFP Desk app admin, otherwise the ones they ran. Filter by `recommendation`.
      security:
      - BearerAuth: []
      parameters:
      - name: recommendation
        in: query
        schema:
          type: string
          enum:
          - go
          - conditional
          - no_bid
      - name: page
        in: query
        description: Page number for pagination
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        description: Number of records per page
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: Paginated assessments
          content:
            application/json:
              schema:
                allOf:
                - "$ref": "#/components/schemas/CollectionEnvelope"
                - type: object
                  properties:
                    items:
                      type: array
                      items:
                        "$ref": "#/components/schemas/RfpAssessmentSummary"
                    meta:
                      "$ref": "#/components/schemas/CollectionPaginationMeta"
        '304':
          "$ref": "#/components/responses/NotModified"
  "/rfp_desk/assessments/{id}":
    get:
      tags:
      - RFP Desk
      summary: Get one assessment
      description: The verdict, weighted breakdown, extracted requirements and any
        hard gate (unmet mandatory requirement or walk-away hit). The RFP's own text
        is never returned.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: The assessment
          content:
            application/json:
              schema:
                type: object
                properties:
                  assessment:
                    "$ref": "#/components/schemas/RfpAssessment"
        '404':
          description: Resource not found
          content:
            application/json:
              schema:
                type: object
                required:
                - error
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine-readable error code
                        example: invalid_request
                      message:
                        type: string
                        description: Human-readable error message
                        example: The request could not be processed
                      details:
                        type: object
                        description: Additional error context
                        additionalProperties: true
                      request_id:
                        type: string
                        description: Unique request identifier for debugging
                        example: req_abc123
                  success:
                    type: boolean
                    example: false
  "/ai_notepad/config":
    get:
      tags:
      - AI Notepad
      summary: AI Notepad feature configuration for the caller's business
      description: |
        The tenant's notepad settings — which AI features are on, the upload
        ceiling, and creation defaults. Doubles as the namespace's health/access
        probe: a business without the app enabled gets the standard JSON 403.

        Realtime credentials are deliberately NOT served here; clients reuse
        `GET /api/v1/chat/config` for Pusher.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Feature configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  ai_notepad:
                    type: object
                    properties:
                      enabled:
                        type: boolean
                      settings:
                        type: object
                        description: Tenant feature flags and defaults. Additive over
                          time — treat unknown keys as forward compatibility.
                        additionalProperties: true
                        properties:
                          ai_enabled:
                            type: boolean
                          audio_overview_enabled:
                            type: boolean
                          meeting_chat_enabled:
                            type: boolean
                          output_formats_enabled:
                            type: boolean
                          content_revision_enabled:
                            type: boolean
                          meeting_comparison_enabled:
                            type: boolean
                          sharing_enabled:
                            type: boolean
                          automations_enabled:
                            type: boolean
                          accountability_digest_enabled:
                            type: boolean
                          calendar_stub_enabled:
                            type: boolean
                          practice_recommendations_enabled:
                            type: boolean
                          max_upload_size_mb:
                            type: integer
                          default_meeting_source:
                            type: string
                          default_template_id:
                            type: string
                            nullable: true
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/pusher/auth":
    post:
      tags:
      - AI Notepad
      summary: Authorize a Pusher subscription for a notepad channel
      description: |
        Signs a subscription to one of the two notepad channels:

          - `private-ai-notepad-meeting-{id}` — per-meeting: status, artifacts,
            transcript and audio-overview events. Requires the caller to be able
            to view that meeting.
          - `private-user-ai-notepad-{userId}-business-{businessId}` — the
            caller's own inbox channel (meeting created/updated/deleted, action
            item assigned). Requires the ids to be the caller's own.

        The response is the raw Pusher auth payload, NOT wrapped in an envelope,
        so it can be handed straight to the Pusher client.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - socket_id
              - channel_name
              properties:
                socket_id:
                  type: string
                channel_name:
                  type: string
                  description: One of the two notepad channel shapes above.
      responses:
        '200':
          description: Pusher auth payload (returned direct, un-enveloped)
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth:
                    type: string
                  channel_data:
                    type: string
                    nullable: true
        '400':
          "$ref": "#/components/responses/NotepadError"
        '403':
          description: Not a channel this caller may subscribe to
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/preferences":
    get:
      tags:
      - AI Notepad
      summary: The caller's own notepad preferences
      description: |
        EFFECTIVE values — the user's stored preferences resolved against the
        tenant policy — plus a `policy` block describing the ceiling the admin
        set. `policy.admin_owned` names the keys a user cannot override.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Effective preferences and the tenant policy
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadPreferences"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
    patch:
      tags:
      - AI Notepad
      summary: Update the caller's own notepad preferences
      description: |
        Stores ONLY the keys present in the request; omitted keys are left
        alone rather than reset. An admin-owned key is refused rather than
        silently ignored. Responds with the same effective shape as GET.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                meeting_detection_enabled:
                  type: boolean
                auto_capture:
                  type: string
                default_notebook_id:
                  type: integer
                  nullable: true
                default_language:
                  type: string
                  nullable: true
                retain_audio_days:
                  type: integer
                notify_on_ready:
                  type: boolean
                  description: The "your meeting notes are ready" inbox message.
                default_template_id:
                  type: integer
                  nullable: true
                capture_consent_granted:
                  type: boolean
                meeting_reminders_enabled:
                  type: boolean
                meeting_reminder_lead_minutes:
                  type: integer
      responses:
        '200':
          description: Updated effective preferences
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadPreferences"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings":
    get:
      tags:
      - AI Notepad
      summary: List the caller's meetings and notes
      description: |
        Everything the caller can view, newest first. Each row carries
        denormalized action-item counts and an `artifacts_status` so a list
        render needs no per-row fan-out.

        **Two paging modes, and they are exclusive.** Without `sort` the list is
        keyset-paginated on `cursor`; WITH `sort` it switches to offset paging on
        `page`, because a keyset cursor over a non-id ordering skips and
        duplicates rows. `meta.pagination.mode` says which mode answered and
        `meta.pagination.ignored` names the paging param that was discarded, so
        a client never loops on page 1 in silence.

        `view` chooses the LIST (live notes, or Recently Deleted); `filter` is
        the chip row stacked on top of it. Deleted rows are excluded unless
        `view=trash`.

        Every vocabulary param (`view`, `filter`, `status`, `sort`) REFUSES an
        unrecognized value with 422 and the offending value echoed — it is never
        answered with a list computed under a different question. The one
        exception is `status=capturing`, which is in the client vocabulary but
        has no server state yet, so it legitimately returns an empty list.

        `meta.filters` echoes the APPLIED narrowing, so a client that reuses a
        stale `cursor` under changed filters can tell.
      security:
      - BearerAuth: []
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: cursor
        in: query
        description: The `meta.next_cursor` from the previous page. Keyset mode only
          — discarded (and reported in `meta.pagination.ignored`) when `sort` is sent.
        schema:
          type: integer
      - name: page
        in: query
        description: 1-based offset page. Offset mode only — discarded (and reported
          in `meta.pagination.ignored`) when `sort` is absent.
        schema:
          type: integer
          default: 1
          minimum: 1
      - name: sort
        in: query
        description: Switches the endpoint from keyset to offset paging. Absent means
          the default id-descending keyset order.
        schema:
          type: string
          enum:
          - edited
          - created
          - title
      - name: view
        in: query
        description: Which list to read. `all` is the default and a legitimate no-op;
          `trash` is Recently Deleted. The Meetings / Notes / Shared narrowings are
          `filter`, not spellings of this param.
        schema:
          type: string
          enum:
          - all
          - trash
          default: all
      - name: filter
        in: query
        description: 'The chip row — what a note IS, as opposed to `status`, which
          is what has happened to it. `deleted` is deliberately absent: Recently Deleted
          is `view=trash`.'
        schema:
          type: string
          enum:
          - all
          - meetings
          - notes
          - shared
      - name: status
        in: query
        description: Client-vocabulary status filter. `capturing` is accepted and
          returns an empty list — no row this server can serialize will ever read
          it.
        schema:
          type: string
          enum:
          - processing
          - completed
          - failed
          - capturing
      - name: notebook_id
        in: query
        description: Must be a notebook the caller can view; a deleted, foreign or
          never-existed id is a 422, not an empty list.
        schema:
          type: integer
      - name: q
        in: query
        description: Case-insensitive substring search across the meeting title, AI
          summary, transcript and the user's own notes — the same four columns the
          web search page reads, so the desktop client and the browser answer the
          same question. No minimum query length.
        schema:
          type: string
      responses:
        '200':
          description: Paginated meeting list
          content:
            application/json:
              schema:
                type: object
                properties:
                  meetings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadMeetingSummary"
                  meta:
                    "$ref": "#/components/schemas/NotepadListMeta"
                  page:
                    type: integer
                    description: Offset mode only (`sort` sent) — the shipped root-level
                      twin of `meta.page`. Absent in keyset mode.
                  has_more:
                    type: boolean
                    description: Offset mode only — the shipped root-level twin of
                      `meta.has_more`. Absent in keyset mode.
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          description: An unrecognized `view` / `filter` / `status` / `sort` value,
            or a `notebook_id` the caller cannot browse. `error.code` is one of `unsupported_view`,
            `unsupported_filter`, `unsupported_status`, `unsupported_sort`, `notebook_not_found`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
    post:
      tags:
      - AI Notepad
      summary: Create a meeting or a note
      description: |
        `source` takes the CLIENT vocabulary. `paste` and `quick_note` carry
        their content inline and enqueue the AI pipeline immediately; `upload`
        creates the row first and the recording is attached by
        `POST /meetings/{id}/audio`; `system_audio` / `voice_note` are the
        live-capture sources.

        Honours `Idempotency-Key`, so a retried create returns the original
        meeting rather than a duplicate.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - source
              properties:
                source:
                  type: string
                  enum:
                  - system_audio
                  - voice_note
                  - upload
                  - paste
                  - quick_note
                title:
                  type: string
                text:
                  type: string
                  description: Body for `paste` / `quick_note`.
                note:
                  type: string
                  description: Extra user notes carried alongside a pasted transcript.
                notebook_id:
                  type: integer
                  nullable: true
                template_id:
                  type: integer
                  nullable: true
                  description: 'One of the tenant''s active note templates (`GET /ai_notepad/note_templates`).
                    Links it to the note: a recording, upload or pasted transcript
                    takes the template''s section headings when processing completes,
                    and a typed note''s first enhance runs under it. Not a body pre-fill.
                    422 `template_not_found` for any other id.'
                occurred_at:
                  type: string
                  format: date-time
                  description: Defaults to now when ABSENT. A value that is present
                    but unparseable or outside the supported year range is a 422,
                    never a silent substitution of now.
                occurred_at_edge:
                  type: string
                  enum:
                  - start
                  - end
                  description: 'Which end of the conversation `occurred_at` is, so
                    the note is named after the calendar meeting that was running
                    then: `end` for the moment a capture stopped, `start` for when
                    it began. Defaults to `end` when `occurred_at` is absent and `duration_sec`
                    is sent; otherwise the server infers it, which is wrong for a
                    capture uploaded later than it is long. Any other value is a 422
                    `invalid_occurred_at_edge`.'
                language:
                  type: string
                  nullable: true
                duration_sec:
                  type: integer
                  description: Client-measured capture length in whole seconds, for
                    the desktop dictation flow where there is no audio for the pipeline
                    to measure. Non-positive values are ignored.
                calendar_event_id:
                  type: string
                  nullable: true
                  description: The occurrence this note is being taken for, as handed
                    back by `GET /meetings/upcoming`. Send it with `calendar_provider`
                    so the note the desktop makes and the note the web makes for one
                    meeting are the same row.
                calendar_provider:
                  type: string
                  nullable: true
      responses:
        '200':
          description: A note for this `calendar_event_id` already existed, so nothing
            was created and the existing one is handed back. Same body as the 201;
            `processing.reason` is `already_exists`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadCreateResponse"
        '201':
          description: The created meeting
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadCreateResponse"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/upcoming":
    get:
      tags:
      - AI Notepad
      summary: Calendar meetings starting soon
      description: |
        The pre-meeting feed the desktop client raises its "your meeting starts
        in N minutes — start AI Notepad?" prompt from. Same resolver the web and
        `/m/` prompts poll, so the surfaces cannot disagree.

        `meta.enabled` reflects the tenant's calendar-stub setting;
        `meta.next_poll_in_seconds` is the server telling the client how long to
        wait before asking again.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Upcoming calendar meetings
          content:
            application/json:
              schema:
                type: object
                properties:
                  meetings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadUpcomingMeeting"
                  meta:
                    type: object
                    description: 'One key set in both branches, with an explicit null
                      or zero where a value does not apply — a caller with the prompt
                      switched off is answered `enabled: false` and a 200, never a
                      403.'
                    properties:
                      enabled:
                        type: boolean
                      lead_minutes:
                        type: integer
                        nullable: true
                      next_poll_in_seconds:
                        type: integer
                        nullable: true
                        description: How long to wait before asking again. Null means
                          stand down.
                      count:
                        type: integer
                        description: Rows returned in this response.
                      max_results:
                        type: integer
                        description: 'The server-side bound on the feed. There is
                          no cursor: this is a rolling window the client re-polls,
                          not a list it pages through.'
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/meetings/upcoming/dismiss":
    post:
      tags:
      - AI Notepad
      summary: Answer the pre-meeting pop-up with "Not now"
      description: |
        Remembers, for the caller and one calendar occurrence, that the pop-up
        was answered. The web prompt and the phone sheet read the same list, so
        none of them offers it again, and the next `GET /meetings/upcoming`
        marks the row `dismissed: true`. Repeating it changes nothing.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - calendar_event_id
              properties:
                calendar_event_id:
                  type: string
                  description: The key `GET /meetings/upcoming` returned.
      responses:
        '200':
          description: Remembered
          content:
            application/json:
              schema:
                type: object
                properties:
                  dismissed:
                    type: boolean
                  calendar_event_id:
                    type: string
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '422':
          description: The key is not a calendar occurrence key.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}":
    get:
      tags:
      - AI Notepad
      summary: Meeting detail
      description: |
        The list shape plus everything the detail pane needs: the note body in
        both formats, capability flags, a signed expiring recording URL, the
        audio-overview state, the source document's web URL, and the public
        share-link state.

        `share_link` is always present and is `null` until a public link exists.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Meeting detail
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMeetingDetail"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
    delete:
      tags:
      - AI Notepad
      summary: Delete a meeting
      description: |
        Moves the meeting into Recently Deleted, recoverable for 30 days by
        `POST /meetings/{id}/restore`; a scheduled sweep purges it after that.
        Only the creator or an `owner` collaborator may delete — shared editors
        and viewers cannot.

        Delete-level (owner decision 2026-10-04): requires the `destructive:ai_notepad`
        scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Deleted. `deleted` is true and `deleted_at` starts the purge
            countdown.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          description: The caller is not the creator or an `owner` collaborator, or
            the note is already in Recently Deleted (`meeting_deleted`) — re-deleting
            would restart the 30-day purge clock on a note the user is trying to recover.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The row vanished between the load and the write (`meeting_delete_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/restore":
    post:
      tags:
      - AI Notepad
      summary: Restore a meeting from Recently Deleted
      description: Takes the same permission as deleting — it is the inverse of it,
        not a lesser action. Answers the shared terse ack with `deleted_at` back to
        null, so a client merging it into its cached row drops the purge countdown
        and knows where the restored note sorts.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Restored, with `deleted` back to false
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The note is not in Recently Deleted (`meeting_not_deleted`),
            or the row vanished under the request (`meeting_restore_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/pin":
    post:
      tags:
      - AI Notepad
      summary: Pin a meeting to the top of the list
      description: Pinned rows sort above the chosen sort order rather than instead
        of it.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Pin state, in the shared terse-ack shape
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          description: Only the note's creator or someone it was shared with directly
            as an editor or owner may pin it, or the note is in Recently Deleted (`meeting_deleted`)
            — pinning writes the column every viewer's list is ordered by.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The row vanished between the load and the write (`meeting_pin_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
    delete:
      tags:
      - AI Notepad
      summary: Unpin a meeting
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Pin state, with `pinned_at` cleared
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadMutationAck"
        '403':
          description: Only the note's creator or someone it was shared with directly
            as an editor or owner may unpin it, or the note is in Recently Deleted
            (`meeting_deleted`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: The row vanished between the load and the write (`meeting_unpin_failed`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/notebook":
    patch:
      tags:
      - AI Notepad
      summary: File a meeting into a notebook, or take it out
      description: |
        A blank / null `notebook_id` means "take it out of its notebook". A
        narrow member action rather than a broad meeting PATCH, so that title
        and visibility cannot be silently accepted and dropped.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notebook_id:
                  type: integer
                  nullable: true
                  description: Null or blank removes the meeting from its notebook.
      responses:
        '200':
          description: The updated meeting
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingEntity"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/{id}/workspace":
    patch:
      tags:
      - AI Notepad
      summary: Link a meeting to a workspace, move the link, or remove it
      description: |
        A linked note's action items become that workspace's to-dos when tasks
        are created from them. Linking also posts the note's summary to the
        workspace's message board — only when every active member of the
        workspace can already open the note, and never on a workspace anyone
        in the organization can join or preview; `summary.message` says what
        happened, in the sentence the web shows. A blank / null `workspace_id`
        removes the link (to-dos already created there stay). The target must be
        an active workspace the caller is a member of with a role that can add
        to-dos (not a viewer); anything else answers 404 `workspace_not_found`.
        Only the note's creator or someone it was shared with directly as an
        editor or owner may change the link (403).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                workspace_id:
                  type: integer
                  nullable: true
                  description: Null or blank removes the link.
      responses:
        '200':
          description: The updated meeting, and what happened to its summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingEntity"
                  summary:
                    type: object
                    nullable: true
                    description: Null when the link was removed.
                    properties:
                      posted:
                        type: boolean
                      outcome:
                        type: string
                        description: posted, pending_review or already_posted when
                          posted; otherwise no_summary, not_visible, refused or not_linkable.
                      message:
                        type: string
        '403':
          description: Only the note's creator or someone it was shared with directly
            as an editor or owner can change which workspace it's linked to.
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/linkable_accounts":
    get:
      tags:
      - AI Notepad
      summary: Accounts a note can be linked to
      description: 'The business''s Mango CS accounts the caller may link a note to
        — the ones they own (CSM or AE) come first with `mine: true`, the same "My
        accounts / All accounts" split the web picker shows. `q` narrows by name or
        domain; at most 50 rows are returned, so search rather than scroll. 403 `mango_cs_unavailable`
        when the Mango CS app is off for the business or not available to the caller
        — hide the account controls on that.'
      security:
      - BearerAuth: []
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
          example: river
      responses:
        '200':
          description: The linkable accounts, the caller's own first
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        domain:
                          type: string
                          nullable: true
                        mine:
                          type: boolean
                  q:
                    type: string
                    nullable: true
        '403':
          description: "`mango_cs_unavailable`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/account":
    post:
      tags:
      - AI Notepad
      summary: Link the note to a Mango CS account
      description: 'The desktop half of "assign an account to a note": links the note
        to `crm_account_id` (the web''s "Link to an account") and answers the refreshed
        meeting with `account` set, so the client redraws without a refetch. Editors
        of the note only; the note must not be in Recently Deleted. Same Mango CS
        gate as the picker.'
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - crm_account_id
              properties:
                crm_account_id:
                  type: integer
                  example: 142
      responses:
        '200':
          description: The refreshed meeting (detail shape) with `account` set
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingDetail"
        '403':
          description: "`forbidden` (not an editor) or `mango_cs_unavailable`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          description: The note, or the account (`account_not_found`), is not there
    delete:
      tags:
      - AI Notepad
      summary: Unlink the note from its account
      description: 'The web''s "Unlink". 422 `meeting_not_linked` when there is nothing
        to unlink. Answers the refreshed meeting with `account: null`.'
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: The refreshed meeting with `account` cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingDetail"
        '403':
          description: "`forbidden` or `mango_cs_unavailable`"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: "`meeting_not_linked`"
  "/ai_notepad/meetings/{id}/account/suggestion/dismiss":
    post:
      tags:
      - AI Notepad
      summary: Dismiss the note's customer suggestion
      description: '"Not this customer" on the detail payload''s `account_suggestion`
        (finding #4182): Mango CS''s automatic linker only suggests the customer a
        note concerns, and its owner confirms (POST /ai_notepad/meetings/{id}/account
        with the suggested `crm_account_id`) or dismisses it here. A dismissed account
        is never suggested for this note again. Same right as confirming; the note
        must not be in Recently Deleted. Answers the refreshed meeting with `account_suggestion:
        null`.'
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: The refreshed meeting with `account_suggestion` cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingDetail"
        '403':
          description: "`forbidden` (not the note's creator or a direct owner, or
            a direct editor while “Editors can pass a note on” is on), `meeting_deleted`
            or `mango_cs_unavailable`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          description: The note is not there, or it carries no suggestion (`suggestion_not_found`)
  "/ai_notepad/meetings/{id}/reshape":
    post:
      tags:
      - AI Notepad
      summary: Reshape the AI notes under a note template
      description: The API twin of the web's Enhance ▾ → "Reshape the AI notes". `"auto"`
        lets the model pick the sections (and clears the note's template link); a
        template id makes that template's section headings the insight sections and
        the summary's structure, and the note remembers it (`note_template` on the
        detail). Open to every source with text — a recording's transcript as much
        as a typed note. Rewrites the summary and the insights only; action items
        are left alone. Returns 202 with the meeting back in `processing`; completion
        arrives via the realtime `artifacts:ready` event, exactly like regenerate.
        Templates come from `GET /ai_notepad/note_templates`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - note_template_id
              properties:
                note_template_id:
                  oneOf:
                  - type: string
                    enum:
                    - auto
                  - type: integer
                  description: '`"auto"`, or the id of one of the tenant''s active
                    note templates.'
      responses:
        '202':
          description: Reshape enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
                  note_template:
                    type: object
                    nullable: true
                    description: The template the notes are being reshaped to; null
                      for `auto`.
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
        '403':
          description: Not an editor, the note is in Recently Deleted, or AI processing
            is disabled by the administrator (`ai_processing_disabled`).
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '409':
          description: "`already_processing` — the note is being processed right now."
        '422':
          description: "`note_template_id_required`, `template_not_found` (not one
            of the tenant's active templates), `nothing_to_reshape` (no text yet),
            or `invalid_transition`."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '503':
          description: The AI pipeline could not be queued (`processing_unavailable`).
            The note is marked `failed`; repeat the request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/note_templates":
    get:
      tags:
      - AI Notepad
      summary: The tenant's note templates
      description: 'The same `active` set the web''s pickers offer — every id here
        is one `POST /meetings` (`template_id`), `POST /meetings/{id}/reshape` and
        `POST /meetings/{id}/section/revise` (`revision_action: template`) accept.
        `sections` are the headings the AI notes take under the template. `default_template_id`
        is the effective default for the caller (their own preference over the admin''s),
        spelled as GET /config spells it.'
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Templates, by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadNoteTemplate"
                  default_template_id:
                    type: string
                    nullable: true
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/meetings/{id}/notes":
    patch:
      tags:
      - AI Notepad
      summary: Replace the note body (the editor's autosave target)
      description: |
        Called repeatedly on a debounce, so it is cheap and idempotent.

        Send `notes_html`. It is the canonical body, and it WINS whenever the
        key is present — so a client sending rich text alongside a stale
        markdown copy of it keeps the rich text.

        `notes` (and its `user_notes_markdown` alias) are DEPRECATED and
        accepted only until the pre-flip desktop build has rolled out. Markdown
        was retired as a write format because a client round-tripping a body
        through it silently flattens tables, highlights and images; a client
        that only ever had markdown is not round-tripping anything, which is why
        the fallback is safe in the meantime. Do not write new clients against
        it.

        A request carrying no body key at all answers 422 `notes_required`, and
        so does an explicitly null `notes_html` — a dropped or lost key must
        never be read as "erase the note". An explicit `""` clears the body,
        which is a real edit.

        The response echoes BOTH formats: the server derives whichever one you
        did not send, and the markdown copy is what the AI pipeline and search
        read.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: One body key is required. `notes_html` for every current
                client; the markdown pair is the deprecated pre-flip shape.
              properties:
                notes_html:
                  type: string
                  description: The rich-text body. `""` clears it. Wins over the pair
                    below.
                notes:
                  type: string
                  deprecated: true
                  description: Markdown body, pre-flip clients only. The server derives
                    the HTML from it.
                user_notes_markdown:
                  type: string
                  deprecated: true
                  description: Alias of `notes`.
              anyOf:
              - required:
                - notes_html
              - required:
                - notes
              - required:
                - user_notes_markdown
      responses:
        '200':
          description: The saved body in both formats
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  notes:
                    type: string
                    nullable: true
                    description: Server-derived markdown twin.
                  notes_html:
                    type: string
                    nullable: true
                  status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The server may have flipped this — writing to a failed
                      note resets it.
                  saved_at:
                    type: string
                    format: date-time
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: |
            `not_a_note` (only a note's body is directly editable — use the
            section editor for AI output), `notes_required`, or
            `content_too_long`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/speakers/{speaker_id}":
    patch:
      tags:
      - AI Notepad
      summary: Name a transcript speaker
      description: Names one speaker ("Speaker A" → the person) for every reader;
        a blank `name` removes the name and the roster's own label returns. Editors
        only; refused on a note in Recently Deleted.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - "$ref": "#/components/parameters/NotepadSpeakerId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 80
      responses:
        '200':
          description: Named. The roster, the live suggestions and whether the notes
            now predate a name.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSpeakerNaming"
        '403':
          "$ref": "#/components/responses/NotepadError"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/speakers/{speaker_id}/suggestion/accept":
    post:
      tags:
      - AI Notepad
      summary: Accept a "Who is Speaker A?" suggestion
      description: Names the speaker the suggestion proposed, through the same store
        the rename writes. Never over a name somebody typed since — that answers 409
        `speaker_already_named`. Editors only; refused on a note in Recently Deleted
        (403 `meeting_deleted`).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - "$ref": "#/components/parameters/NotepadSpeakerId"
      responses:
        '200':
          description: Accepted.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSpeakerNaming"
        '403':
          "$ref": "#/components/responses/NotepadError"
        '404':
          description: No such note, or no pending suggestion for that speaker (`suggestion_not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '409':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/speakers/{speaker_id}/suggestion/dismiss":
    post:
      tags:
      - AI Notepad
      summary: Dismiss a "Who is Speaker A?" suggestion
      description: Retires the suggestion for this transcript; it is not offered again.
        Editors only; refused on a note in Recently Deleted.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - "$ref": "#/components/parameters/NotepadSpeakerId"
      responses:
        '200':
          description: Dismissed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSpeakerNaming"
        '403':
          "$ref": "#/components/responses/NotepadError"
        '404':
          description: No such note, or no pending suggestion for that speaker (`suggestion_not_found`).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/transcript":
    get:
      tags:
      - AI Notepad
      summary: The meeting transcript, in ordered segments
      description: |
        `ready: false` with an empty `segments` array is a normal "not
        transcribed yet" answer, NOT an error — the client waits for the
        realtime `transcript:final` event.

        Segments are derived from the stored transcript at request time, so
        `start_ms`, `end_ms` and `confidence` are currently always null; there is
        no per-segment timing yet.

        `status` here is the MEETING vocabulary while the sibling artifacts read
        puts the ARTIFACTS vocabulary under that same key, and the two COLLIDE on
        `failed`. `meeting_status` and `artifacts_status` are the unambiguous
        pair, present on all three of this meeting's reads and always meaning the
        same thing — prefer them.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - name: limit
        in: query
        schema:
          type: integer
          default: 200
          minimum: 1
          maximum: 500
      - name: cursor
        in: query
        description: The `meta.next_cursor` (a segment `seq`) from the previous page.
        schema:
          type: integer
      - name: full
        in: query
        description: Return every segment in one response, ignoring `limit`.
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Transcript segments (possibly not ready yet)
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The MEETING vocabulary. Ambiguous by name across
                      this meeting's three reads — see `meeting_status`.
                  meeting_status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The meeting's own lifecycle, under a name that cannot
                      be confused with the artifacts one.
                  artifacts_status:
                    type: string
                    enum:
                    - pending
                    - ready
                    - failed
                    description: Whether the AI output exists yet. `pending` means
                      show a skeleton, not an error.
                  ready:
                    type: boolean
                    description: False until a transcript exists.
                  speakers:
                    type: array
                    description: The speaker roster. Every element carries an `id`
                      (`s1`, `s2`, …) that the segments' `speaker` resolves against
                      — derived from the element's position when the stored roster
                      has none.
                    items:
                      type: object
                      additionalProperties: true
                  speaker_suggestions:
                    type: array
                    description: '"Who is Speaker A?" — the same live suggestions
                      the detail payload carries, beside the roster they name.'
                    items:
                      "$ref": "#/components/schemas/NotepadSpeakerSuggestion"
                  segments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadTranscriptSegment"
                  meta:
                    type: object
                    properties:
                      full:
                        type: boolean
                      limit:
                        type: integer
                        description: Page SIZE, in both modes.
                      count:
                        type: integer
                        description: Segments actually returned.
                      has_more:
                        type: boolean
                      next_cursor:
                        type: integer
                        nullable: true
                        description: Always null in `full` mode, which discards the
                          cursor — read the rest with the paged mode, which honours
                          it and terminates.
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/artifacts":
    get:
      tags:
      - AI Notepad
      summary: The AI output — summary and insight sections
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Artifacts, with a status the client renders skeletons from
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadArtifacts"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/regenerate":
    post:
      tags:
      - AI Notepad
      summary: Re-run the AI pipeline for a meeting's artifacts
      description: Returns 202 with the meeting back in `processing`; completion arrives
        via the realtime `artifacts:ready` event.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                target:
                  type: string
                  enum:
                  - summary
                  - insights
                  - action_items
                  - all
                  default: all
      responses:
        '202':
          description: Regeneration enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
        '503':
          description: The AI pipeline could not be queued (`processing_unavailable`).
            The note is marked `failed`; repeat the request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/convert":
    post:
      tags:
      - AI Notepad
      summary: Convert a plain note into a meeting note
      description: Runs the note's own text through the AI pipeline. Refused for an
        empty note — its text is what a meeting note is summarized from.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '202':
          description: Conversion enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeetingEntity"
                  truncated_fields:
                    type: array
                    description: Which body fields the server's length ceiling cut
                      — the same key and meaning the create carries. Always an array
                      so the key set is stable; empty for `mode=notes`, which pastes
                      nothing.
                    items:
                      type: string
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
        '503':
          description: The AI pipeline could not be queued (`processing_unavailable`).
            The note is marked `failed`; repeat the request.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/transcribe_text":
    post:
      tags:
      - AI Notepad
      summary: Submit or replace a pasted transcript
      description: The text path — no ASR. Only valid for a `paste`-sourced meeting;
        a quick note or a recording-backed meeting is refused 422.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - text
              properties:
                text:
                  type: string
      responses:
        '202':
          description: 'Text saved. `processing.started` says whether the pipeline
            was enqueued; `reason: processing_unavailable` means it could not be,
            and the note is then `failed` (reprocess it with `regenerate`).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
                  processing:
                    type: object
                    description: Whether the pipeline was actually enqueued — the
                      same shape the create answers with, so a client can stop rendering
                      a progress state for a job that was never started.
                    properties:
                      started:
                        type: boolean
                      reason:
                        type: string
                  truncated_fields:
                    type: array
                    description: Which body fields the server's length ceiling cut.
                      Always an array; empty is the normal case.
                    items:
                      type: string
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/audio":
    post:
      tags:
      - AI Notepad
      summary: Attach a recording (direct multipart upload)
      description: |
        The small-file transport. Enforces the tenant's size ceiling and the
        allowed formats, then enqueues the AI pipeline. Use the resumable
        `audio/init` + `audio/complete` pair for large files.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '202':
          description: Attached and pipeline enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
        '413':
          description: Larger than the tenant's upload ceiling
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '415':
          description: Unsupported audio format
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
        '502':
          description: File storage refused the recording (`upload_storage_unavailable`).
            Nothing was changed on the note; send the file again. An infected file
            answers 422 `upload_blocked`; nothing is stored.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '503':
          description: 'The recording is attached, but the AI pipeline could not be
            queued (`processing_unavailable`). The note is marked `failed`; repeat
            the request, or reprocess it with `POST /ai_notepad/meetings/{id}/regenerate`.
            Also `scanner_unavailable`: virus scanning is down and this workspace
            refuses uploads meanwhile; nothing was stored, so send the file again
            in a few minutes.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/audio/init":
    post:
      tags:
      - AI Notepad
      summary: Begin a presigned recording upload (deprecated)
      deprecated: true
      description: 'DEPRECATED — use Upload Sessions (`POST /uploads/sessions` with
        `purpose: "ai_notepad"`), which sends the file through the API in resumable
        parts and works on every storage provider, then finalize with `audio/complete`
        exactly as before. This leg returns a presigned direct-upload target the client
        PUTs the file to; it remains for clients already in the field and will be
        removed once they have moved.'
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - filename
              - byte_size
              - content_type
              properties:
                filename:
                  type: string
                byte_size:
                  type: integer
                content_type:
                  type: string
                checksum:
                  type: string
      responses:
        '200':
          description: Upload target
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  upload:
                    type: object
                    properties:
                      signed_blob_id:
                        type: string
                      url:
                        type: string
                      byte_size:
                        type: integer
                      headers:
                        type: object
                        additionalProperties:
                          type: string
        '413':
          description: Larger than the tenant's upload ceiling
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '415':
          description: Unsupported audio format
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/audio/complete":
    post:
      tags:
      - AI Notepad
      summary: Finalize a resumable recording upload
      description: Attaches the uploaded blob to the meeting and enqueues the AI pipeline.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - signed_blob_id
              properties:
                signed_blob_id:
                  type: string
                  description: From the `audio/init` response (or an upload session's
                    hand-off). The id `audio/init` returns is accepted only here —
                    no other endpoint that takes a signed blob id will.
      responses:
        '202':
          description: Attached and pipeline enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting:
                    "$ref": "#/components/schemas/NotepadMeeting"
        '403':
          description: "`ai_processing_disabled` — an admin has switched AI Notepad's
            processing off for this workspace; nothing was attached."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '413':
          description: Larger than the tenant's upload ceiling (as declared at `audio/init`)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '415':
          description: Unsupported audio format
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          description: Refused, nothing attached. `upload_blocked` — the virus scan
            found malware in the uploaded file, which is deleted (final; do not retry).
            `upload_incomplete` — the PUT never reached storage, or the stored bytes
            do not match the checksum declared at `audio/init`; run `audio/init` and
            upload again. Other codes as `NotepadError`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '503':
          description: Three causes, told apart by `error.code`. `scanner_unavailable`
            — uploads are paused while virus scanning is unavailable; nothing was
            attached and the uploaded file is kept, so repeat this request in a few
            minutes. `storage_unavailable` — file storage could not be read for the
            scan; nothing was attached and the uploaded file is kept, so repeat this
            request. `processing_unavailable` — the recording is attached, but the
            AI pipeline could not be queued. The note is marked `failed`; reprocess
            it with `POST /ai_notepad/meetings/{id}/regenerate` (repeating this request
            with the same `signed_blob_id` works only for the note's creator, because
            the blob is now attached).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/images":
    post:
      tags:
      - AI Notepad
      summary: Upload an image for the note body
      description: |
        Quill inlines a pasted or chosen image as a base64 data URI, which pushes
        a note past the body length cap and stops it saving at all. This uploads
        instead and returns a URL to embed.

        The URL is permanent, absolute and bearer-readable by design: it is
        written INTO the stored note body, an `<img>` tag cannot send an
        Authorization header, and the same body is rendered by both the web app
        and the desktop client (whose renderer origin is not the tenant host, so
        a path-only URL would never load there). `url` may be null if the URL
        could not be built — nothing is embedded in that case.

        The file is stored as a platform `MediaItem` subject-scoped to the
        meeting, so type and size are enforced by the shared upload gate.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201':
          description: The uploaded image
          content:
            application/json:
              schema:
                type: object
                properties:
                  image:
                    type: object
                    properties:
                      url:
                        type: string
                        nullable: true
                        description: Absolute, permanent, embeddable. Null when the
                          URL could not be built.
                      byte_size:
                        type: integer
                        nullable: true
                      content_type:
                        type: string
                        nullable: true
        '403':
          description: Caller cannot edit this note
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          description: "`file_required` or `invalid_image`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/section":
    patch:
      tags:
      - AI Notepad
      summary: Replace one AI section's content
      description: |
        The section editor. `section` is the SERVER vocabulary — note
        `insights`, not the client's `key_insights` tab name.

        Responds with the whole artifacts body so the client can re-render the
        section without a refetch.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - section
              - content
              properties:
                section:
                  "$ref": "#/components/schemas/NotepadSectionName"
                content:
                  description: A string for `summary`; a structured list for `action_items`
                    / `insights`.
                  oneOf:
                  - type: string
                  - type: array
                    items:
                      type: object
                      additionalProperties: true
      responses:
        '200':
          description: The saved section, with refreshed artifacts
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSectionSaveResponse"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/section/revise":
    post:
      tags:
      - AI Notepad
      summary: AI-revise one section
      description: 'Hands the section''s current content back to the model and saves
        the result — the web''s AI Actions menu. Responds with the same body as a
        section save. Synchronous: the model is called inline. Gated on the tenant''s
        `content_revision_enabled` toggle and the AI kill switch (403 `content_revision_disabled`
        / `ai_processing_disabled`).'
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - section
              - revision_action
              properties:
                section:
                  "$ref": "#/components/schemas/NotepadSectionName"
                revision_action:
                  type: string
                  enum:
                  - rewrite
                  - expand
                  - refine
                  - simplify
                  - custom
                  - template
                  description: "`custom` takes the whole directive in `instructions`
                    (required for it); `template` reshapes the SUMMARY under a note
                    template's headings and takes `template_id` (summary only — refused
                    on the schema-locked sections)."
                instructions:
                  type: string
                  description: Free-text steer — optional for the presets, required
                    for `custom`.
                template_id:
                  type: integer
                  description: Required for `template` — one of the tenant's active
                    note templates (`GET /ai_notepad/note_templates`).
      responses:
        '200':
          description: The revised section
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSectionSaveResponse"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '409':
          description: "`section_changed_during_revision` — the section was changed
            (an item ticked, a version restored, another edit) while the model was
            rewriting it, so nothing was replaced. Re-read the section and call again
            to revise the current version."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/section/versions":
    get:
      tags:
      - AI Notepad
      summary: Edit history for one AI section
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - name: section
        in: query
        required: true
        schema:
          "$ref": "#/components/schemas/NotepadSectionName"
      responses:
        '200':
          description: Versions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  section:
                    "$ref": "#/components/schemas/NotepadSectionName"
                  versions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadSectionVersion"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/section/restore":
    post:
      tags:
      - AI Notepad
      summary: Roll a section back to an earlier version
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - section
              - version_id
              properties:
                section:
                  "$ref": "#/components/schemas/NotepadSectionName"
                version_id:
                  type: integer
      responses:
        '200':
          description: The restored section
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadSectionSaveResponse"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/action_items":
    get:
      tags:
      - AI Notepad
      summary: A meeting's action items
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      - name: limit
        in: query
        description: Defaults to the maximum, which is what shipped clients get.
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Action items with assignee identity and due labels
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The MEETING vocabulary. Ambiguous by name across
                      this meeting's three reads — see `meeting_status`.
                  meeting_status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The meeting's own lifecycle, unambiguously named.
                  artifacts_status:
                    type: string
                    enum:
                    - pending
                    - ready
                    - failed
                    description: Whether the AI output exists yet.
                  action_items:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadActionItem"
                  meta:
                    type: object
                    description: Counts are computed IN THE DB, never off the returned
                      page, so a capped page never makes the tab's totals lie.
                    properties:
                      open_count:
                        type: integer
                      total_count:
                        type: integer
                      limit:
                        type: integer
                      has_more:
                        type: boolean
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
    post:
      tags:
      - AI Notepad
      summary: Add an action item to a meeting
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                assignee_user_id:
                  type: integer
                  nullable: true
                due_date:
                  type: string
                  format: date
                  nullable: true
                commitment_type:
                  type: string
                  enum:
                  - hard
                  - soft
      responses:
        '201':
          description: The created action item
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_item:
                    "$ref": "#/components/schemas/NotepadActionItem"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/action_items":
    get:
      tags:
      - AI Notepad
      summary: Action items assigned to the caller, or on the caller's notes, across
        meetings
      description: The "My Action Items" view. Each row carries a lightweight meeting
        reference so the client can deep-link back to its source. With `from=my_notes`
        it lists instead the items on notes the caller wrote, whoever they are assigned
        to — each row's `assignee` names that person (My Items' "From my notes" list).
      security:
      - BearerAuth: []
      parameters:
      - name: assignee
        in: query
        description: Only `me` is supported. Leave it out with `from=my_notes`.
        schema:
          type: string
          enum:
          - me
          default: me
      - name: from
        in: query
        description: 'Whose items: `assigned` (the items assigned to the caller) or
          `my_notes` (the items on notes the caller wrote). Any other value is a 422
          `unsupported_list`.'
        schema:
          type: string
          enum:
          - assigned
          - my_notes
          default: assigned
      - name: limit
        in: query
        schema:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
      - name: cursor
        in: query
        schema:
          type: integer
      responses:
        '200':
          description: Paginated cross-meeting action items
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_items:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/NotepadActionItem"
                      - type: object
                        properties:
                          meeting:
                            "$ref": "#/components/schemas/NotepadMeetingRef"
                  meta:
                    "$ref": "#/components/schemas/NotepadCursorMeta"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/action_items/{id}":
    patch:
      tags:
      - AI Notepad
      summary: Update an action item
      description: |-
        Edits the title, assignee, due date or commitment type, and toggles `done` (which maps onto the server's pending/completed status) or sets `status` directly. An assignee change emits the realtime `action_item:assigned` event.
        **Required Scopes:** `write:ai_notepad`, and also `destructive:ai_notepad` when `status` is `cancelled` — cancelling an action item is a final act (owner decision 2026-10-04, #6721); a token without it is refused with 403 `insufficient_permissions` ("Required scope: destructive:ai_notepad"). Done or back to pending needs only the write scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                done:
                  type: boolean
                status:
                  type: string
                  enum:
                  - pending
                  - completed
                  - cancelled
                  description: Wins over `done` when both are sent; `cancelled` also
                    needs `destructive:ai_notepad`.
                assignee_user_id:
                  type: integer
                  nullable: true
                due_date:
                  type: string
                  format: date
                  nullable: true
                commitment_type:
                  type: string
                  enum:
                  - hard
                  - soft
      responses:
        '200':
          description: The updated action item
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_item:
                    "$ref": "#/components/schemas/NotepadActionItem"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    put:
      tags:
      - AI Notepad
      summary: Update an action item (PUT alias)
      description: 'Alias of PATCH on this path — Rails'' `resources` answers both
        verbs identically. Documented because it is callable, not because it is preferred;
        new clients should use PATCH. Same scopes: `write:ai_notepad`, and also `destructive:ai_notepad`
        when `status` is `cancelled`.'
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                done:
                  type: boolean
                status:
                  type: string
                  enum:
                  - pending
                  - completed
                  - cancelled
                  description: Wins over `done` when both are sent; `cancelled` also
                    needs `destructive:ai_notepad`.
                assignee_user_id:
                  type: integer
                  nullable: true
                due_date:
                  type: string
                  format: date
                  nullable: true
                commitment_type:
                  type: string
                  enum:
                  - hard
                  - soft
      responses:
        '200':
          description: The updated action item
          content:
            application/json:
              schema:
                type: object
                properties:
                  action_item:
                    "$ref": "#/components/schemas/NotepadActionItem"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    delete:
      tags:
      - AI Notepad
      summary: Delete an action item
      description: |
        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:ai_notepad` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  deleted:
                    type: boolean
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{meeting_id}/members":
    get:
      tags:
      - AI Notepad
      summary: Who a meeting is shared with
      description: The creator appears as an immutable synthetic `owner` member. People
        search for the add flow reuses `GET /api/v1/chat/users/search`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      responses:
        '200':
          description: Members
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadMember"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
    post:
      tags:
      - AI Notepad
      summary: Share a meeting with a teammate
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                role:
                  type: string
                  enum:
                  - viewer
                  - editor
                  default: viewer
      responses:
        '201':
          description: The added member
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/NotepadMember"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/meetings/{meeting_id}/members/{id}":
    patch:
      tags:
      - AI Notepad
      summary: Change a member's role
      description: "`id` is the member's USER id. The owner's role is immutable."
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      - name: id
        in: path
        required: true
        description: The member's user id.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - role
              properties:
                role:
                  type: string
                  enum:
                  - viewer
                  - editor
      responses:
        '200':
          description: The updated member
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/NotepadMember"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    put:
      tags:
      - AI Notepad
      summary: Change a member's role (PUT alias)
      description: Alias of PATCH on this path — Rails' `resources` answers both verbs
        identically. Documented because it is callable, not because it is preferred;
        new clients should use PATCH.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - role
              properties:
                role:
                  type: string
                  enum:
                  - viewer
                  - editor
      responses:
        '200':
          description: The updated member
          content:
            application/json:
              schema:
                type: object
                properties:
                  member:
                    "$ref": "#/components/schemas/NotepadMember"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    delete:
      tags:
      - AI Notepad
      summary: Remove a member's access
      description: |
        `id` is the member's USER id. The owner cannot be removed.

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:ai_notepad` scope.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingIdPath"
      - name: id
        in: path
        required: true
        description: The member's user id.
        schema:
          type: integer
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_id:
                    type: integer
                  removed:
                    type: boolean
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/audio_overview":
    get:
      tags:
      - AI Notepad
      summary: The narrated audio overview
      description: "`available: false` is the normal not-generated-yet answer. When
        available, `url` is signed and expiring — refetch rather than storing it."
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Audio overview state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadAudioOverviewDetail"
        '403':
          description: The audio-overview feature is off for this business
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '502':
          description: The overview exists but storage could not mint a URL for it
            (`audio_overview_url_unavailable`). A named, retryable failure rather
            than a 200 carrying a `url` the player will choke on.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
    post:
      tags:
      - AI Notepad
      summary: Generate the audio overview
      description: Returns 202; the client awaits the realtime `audio_overview:ready`
        event, then GETs the signed URL. Requires editor-level membership.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '202':
          description: Generation enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  status:
                    type: string
                    description: Always `generating`. This is the GENERATION's state,
                      not the meeting's — see `meeting_status` for that.
                    enum:
                    - generating
                  state:
                    type: string
                    description: The SAME key the two reads carry, so a client that
                      fires this POST has something to poll on. `generating` here;
                      `ready` / `failed` / `none` on a read.
                    enum:
                    - generating
                  processing:
                    type: object
                    description: Whether THIS request started anything. A generation
                      already in flight for this meeting holds the concurrency key,
                      so the enqueue aborts and nothing new was started — the overview
                      is still being generated, just not by this call.
                    properties:
                      started:
                        type: boolean
                      reason:
                        type: string
                        enum:
                        - queued
                        - already_generating
                  meeting_status:
                    type: string
                    enum:
                    - processing
                    - completed
                    - failed
                    description: The meeting's own lifecycle, unambiguously named.
        '403':
          description: Feature off, or caller lacks editor access
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/meetings/{id}/chat_history":
    get:
      tags:
      - AI Notepad
      summary: The persisted AI-chat conversation for a meeting
      description: Lets a client re-hydrate the chat tab on reopen instead of starting
        blank. The streamed POST persists both turns.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      responses:
        '200':
          description: Chat messages, chronological
          content:
            application/json:
              schema:
                type: object
                properties:
                  meeting_id:
                    type: integer
                  messages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadChatMessage"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/meetings/{id}/chat":
    post:
      tags:
      - AI Notepad
      summary: Ask a question about one meeting (streamed)
      description: |
        **Server-Sent Events, not JSON.** The response is `text/event-stream`
        carrying incremental `delta` frames, a terminal `complete` frame (message
        id, model, usage) and an `error` frame if generation fails mid-stream.
        Answers are grounded in the meeting and carry citations back to
        transcript segments.

        The answer is generated server-side with the server-held model key — no
        model credential ever reaches a client. A short-lived `chat_stream`
        capture token from `POST /captures/token` is re-checked when presented.

        Both turns are persisted; read them back with `chat_history`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                capture_token:
                  type: string
                  description: Optional scoped token from `POST /captures/token`.
      responses:
        '200':
          description: 'An SSE stream of delta / complete / error frames, interleaved
            with `: keepalive` comment lines (about every 10s, after the first real
            frame) that keep the connection from being torn down while idle. Readers
            must skip any line beginning with `:` — it is an SSE comment, not an event.'
          content:
            text/event-stream:
              schema:
                type: string
                description: Newline-delimited SSE frames. Not JSON — do not parse
                  the body as a document.
        '403':
          description: Meeting chat is off for this business, or access denied
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
  "/ai_notepad/chat":
    post:
      tags:
      - AI Notepad
      summary: Ask a question across your notes (streamed)
      description: |
        **Server-Sent Events, not JSON** — same transport as the per-meeting
        chat. `source_scope` chooses which of the caller's visible meetings
        ground the answer.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                source_scope:
                  type: string
                  enum:
                  - all
                  - notebook
                  - selected
                  - date_range
                  default: all
                notebook_id:
                  type: integer
                  nullable: true
                meeting_ids:
                  type: array
                  items:
                    type: integer
                from:
                  type: string
                  format: date
                to:
                  type: string
                  format: date
      responses:
        '200':
          description: 'An SSE stream of delta / complete / error frames, interleaved
            with `: keepalive` comment lines (about every 10s, after the first real
            frame) that keep the connection from being torn down while idle. Readers
            must skip any line beginning with `:` — it is an SSE comment, not an event.'
          content:
            text/event-stream:
              schema:
                type: string
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
  "/ai_notepad/captures/token":
    post:
      tags:
      - AI Notepad
      summary: Mint a short-lived scoped capture token
      description: |
        A stateless, signed grant bound to ONE meeting and one scope, with a
        5-minute ceiling. It exists so a client never holds a model API key:
        it presents this to the streaming surfaces instead. Refresh by calling
        again.

        Only `chat_stream` is issuable today; an unknown scope is a 422 rather
        than a silently-issued token.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - scope
              - meeting_id
              properties:
                scope:
                  type: string
                  enum:
                  - chat_stream
                meeting_id:
                  type: integer
      responses:
        '201':
          description: The minted token
          content:
            application/json:
              schema:
                type: object
                properties:
                  capture_token:
                    type: object
                    properties:
                      token:
                        type: string
                      scope:
                        type: string
                        enum:
                        - chat_stream
                      meeting_id:
                        type: integer
                      expires_at:
                        type: string
                        format: date-time
                      expires_in:
                        type: integer
                        description: Seconds. At most 300.
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          description: Unknown or unsupported scope
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/transcription/tickets":
    post:
      tags:
      - AI Notepad
      summary: Mint a cloud transcription ticket for one capture
      description: |
        The desktop asks for a ticket once per capture and presents it to the
        transcription relay, which holds the provider key — the client never
        sees it. The ticket is a signed token that expires after 5 minutes;
        the relay checks it when the stream opens.

        Every refusal carries a code the desktop turns into an on-device
        capture, checked in this order: capture is off for the organization
        (`capture_disabled_by_policy`), the caller is outside the who-may-record
        policy (`capture_not_permitted`), the organization transcribes on the
        device (`cloud_not_in_effect`, with `reason: provider_unavailable` when
        this environment has no provider key), the relay is not configured
        here (`relay_not_configured`), or the AI Voice credits or the monthly
        transcription budget are used up (`wallet_empty`, 402 — the message
        says which).

        **Token scopes:** `write:ai_notepad`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - session_id
              properties:
                session_id:
                  type: string
                  description: The capture's own id, chosen by the client; bound into
                    the ticket.
                language:
                  type: string
                  maxLength: 35
                  description: The spoken language hint, cut to 35 characters.
                keywords:
                  type: array
                  maxItems: 64
                  description: Words the provider should listen for. Blanks and duplicates
                    are dropped; at most 64 are kept.
                  items:
                    type: string
      responses:
        '200':
          description: The ticket and the relay to present it to.
          content:
            application/json:
              schema:
                type: object
                required:
                - ticket
                - expires_at
                - provider
                properties:
                  ticket:
                    type: string
                    description: The signed ticket. Never log it.
                  expires_at:
                    type: string
                    format: date-time
                    description: Five minutes after it was minted.
                  relay:
                    type: object
                    nullable: true
                    description: Where to open the stream. Null when the environment's
                      relay origin does not resolve to a `wss://` address.
                    properties:
                      url:
                        type: string
                        example: wss://relay.example.com/api/v1/ai_notepad/transcription/stream
                      protocol:
                        type: integer
                        example: 1
                  provider:
                    type: string
                    enum:
                    - meta_muse
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '402':
          description: "`wallet_empty` — the organization's credits or this month's
            transcription budget are used up; capture on the device."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:ai_notepad"),
            `app_not_enabled`, `app_not_accessible`, `capture_disabled_by_policy`,
            `capture_not_permitted`, `cloud_not_in_effect` (with a `reason`) or `relay_not_configured`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          description: "`session_id_required` — `session_id` is missing or blank."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
  "/ai_notepad/meetings/{id}/send_to_chat":
    post:
      tags:
      - AI Notepad
      summary: Post a meeting's sections into a chat room
      description: |
        Composes the chosen sections as Markdown and posts them into a Shifts
        chat room the caller belongs to, through the same path the chat surface
        uses.

        The ONE endpoint in this namespace that requires a token scope —
        `write:chat` — because it writes a chat message. Also re-checks that the
        Chat app is enabled and visible to the caller.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/NotepadMeetingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - room_id
              - sections
              properties:
                room_id:
                  type: integer
                sections:
                  type: array
                  items:
                    type: string
                    enum:
                    - summary
                    - key_insights
                    - action_items
                client_uuid:
                  type: string
                  description: Client-side dedup key for the posted message.
      responses:
        '201':
          description: Posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id:
                    type: integer
                    nullable: true
                  room_id:
                    type: integer
        '403':
          description: |
            `forbidden` (missing write:chat), `app_not_enabled` /
            `app_not_accessible` (AI Notepad), `chat_not_enabled` /
            `chat_not_accessible` (the Chat app is off or not open to the
            caller), or `im_not_allowed`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '404':
          description: "`room_not_found`"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NotepadErrorBody"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/ai_notepad/notebooks":
    get:
      tags:
      - AI Notepad
      summary: Notebooks the caller can file notes into
      description: 'By default every row is a `notebook_id` both write paths accept
        — created by the caller, or shared with them as editor/owner. A client whose
        sidebar is navigation sends `include=readable` to also receive the notebooks
        shared with the caller as Viewer, each with `can_file: false`, so a filing
        picker can keep excluding them.'
      parameters:
      - name: include
        in: query
        required: false
        description: "`readable` adds the view-only shares to the list. Omit it for
          the filing set."
        schema:
          type: string
          enum:
          - readable
      security:
      - BearerAuth: []
      responses:
        '200':
          description: Notebooks
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebooks:
                    type: array
                    items:
                      "$ref": "#/components/schemas/NotepadNotebook"
        '403':
          "$ref": "#/components/responses/NotepadForbidden"
    post:
      tags:
      - AI Notepad
      summary: Create a notebook
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '201':
          description: The created notebook
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebook:
                    "$ref": "#/components/schemas/NotepadNotebook"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
  "/ai_notepad/notebooks/{id}":
    patch:
      tags:
      - AI Notepad
      summary: Rename or re-describe a notebook
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '200':
          description: The updated notebook
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebook:
                    "$ref": "#/components/schemas/NotepadNotebook"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    put:
      tags:
      - AI Notepad
      summary: Rename or re-describe a notebook (PUT alias)
      description: Alias of PATCH on this path — Rails' `resources` answers both verbs
        identically. Documented because it is callable, not because it is preferred;
        new clients should use PATCH.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                description:
                  type: string
                  nullable: true
      responses:
        '200':
          description: The updated notebook
          content:
            application/json:
              schema:
                type: object
                properties:
                  notebook:
                    "$ref": "#/components/schemas/NotepadNotebook"
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadValidationError"
    delete:
      tags:
      - AI Notepad
      summary: Delete a notebook
      description: |-
        The notes inside are not deleted — they are unfiled.
        Delete-level (owner decision 2026-10-04, #6721): requires the `destructive:ai_notepad` scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  deleted:
                    type: boolean
        '404':
          "$ref": "#/components/responses/NotepadNotFound"
        '422':
          "$ref": "#/components/responses/NotepadError"
  "/people/changes/{id}":
    get:
      tags:
      - People Changes
      security:
      - BearerAuth: []
      summary: Fetch one approved profile change
      description: |
        The change a write-back webhook referenced (its `fetch_url`), rebuilt
        from the approved request and the person's record at fetch time — the
        values are the person's CURRENT values for the attributes the change
        writes. Nothing is stored on the ledger row, so a receiver that fetches
        late gets today's value.

        Requires an administrator's token with `read:people_changes`.

        A signed record sent to the same webhook (event type
        `people.compliance_record` — a signed policy acknowledgment, INT-HR-5)
        is fetched and confirmed here too: its `record_type` is
        `compliance_record`, `change_type` is `policy_attestation`, `role` is
        `copy`, and `values` carries `attestation_id`, `policy_id`,
        `policy_title`, `policy_version`, `signed_at`, `cycle_kind`, `door`,
        `signature_method` and `signed_document_sha256`.

        Answers 410 for a change that must not be applied now — anything but
        in flight or confirmed, for example superseded by a newer change to
        the same field, discarded, failed or conflicted; `error.details.status`
        names its state.
      parameters:
      - name: id
        in: path
        required: true
        description: The change id from the webhook's `change_id`.
        schema:
          type: integer
      responses:
        '200':
          description: The change, rebuilt now
          content:
            application/json:
              schema:
                type: object
                properties:
                  change:
                    "$ref": "#/components/schemas/PeopleChange"
              example:
                change:
                  change_id: 812
                  change_type: phone
                  label: Phone Number
                  role: owner
                  subject:
                    user_id: 4521
                    employee_id: E-10042
                    email: jordan@example.com
                  values:
                    phone: "+1 (555) 555-0142"
                  effective_date:
                  approved_at: '2026-09-24T15:02:11Z'
                  sensitive: false
                  status: sent
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks read:people_changes, or its user is not an
            administrator
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '410':
          description: The change must not be applied now (superseded, discarded,
            failed, conflicted…); error.details.status names its state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: gone
                  message: This change is superseded; do not apply it.
                  details:
                    status: superseded
  "/people/changes/{id}/confirmation":
    post:
      tags:
      - People Changes
      security:
      - BearerAuth: []
      summary: Report what happened to a change
      description: |
        The receiver's answer for a change it fetched (its `confirm_url`).

        * `applied` — the change is in the destination system. The change is
          marked confirmed; `external_id` (up to 255 characters) records the
          destination's own id for it.
        * `rejected` — the destination refused it. Final: the change is marked
          failed with `reason` (up to 200 characters) in the outbound log, and
          an administrator can retry it from there.

        Requires an administrator's token with `write:people_changes`.
      parameters:
      - name: id
        in: path
        required: true
        description: The change id from the webhook's `change_id`.
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - applied
                  - rejected
                external_id:
                  type: string
                  maxLength: 255
                  description: The destination's id for the applied change (applied
                    only).
                reason:
                  type: string
                  maxLength: 200
                  description: Why the destination refused it (rejected only).
            example:
              status: applied
              external_id: ION-77
      responses:
        '200':
          description: The change's state after the answer
          content:
            application/json:
              schema:
                type: object
                properties:
                  change:
                    type: object
                    properties:
                      id:
                        type: integer
                      status:
                        type: string
                        description: confirmed after `applied`, failed after `rejected`
                      confirmed_at:
                        type: string
                        format: date-time
                        nullable: true
              example:
                change:
                  id: 812
                  status: confirmed
                  confirmed_at: '2026-09-24T15:04:40Z'
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: The token lacks write:people_changes, or its user is not an
            administrator
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '404':
          "$ref": "#/components/responses/NotFound"
        '410':
          description: The change must not be applied now (superseded, discarded,
            failed, conflicted…); error.details.status names its state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '422':
          description: status is neither "applied" nor "rejected"
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
              example:
                error:
                  code: invalid_status
                  message: status must be "applied" or "rejected".
  "/ai_front_office/booking_requests/{id}/decline":
    post:
      tags:
      - AI Front Office
      summary: Decline a booking request
      description: |
        Declines a booking request that is waiting for review
        (`pending_review`) or approved by a dispatcher (`dispatcher_approved`):
        the request becomes `declined`, a lost outcome is recorded with the
        reason, and a text telling the customer is queued — except for the
        `duplicate_request`, `customer_declined` and `cancelled_by_customer`
        reasons. The text is sent only when the business's texting setting, the
        customer's phone number and their opt-out status allow it.

        **Required Scopes:** `destructive:ai_front_office`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:ai_front_office` scope; `write:ai_front_office` (which
        approve asks) is not enough. A token without it is refused with 403
        `insufficient_permissions` ("Required scope:
        destructive:ai_front_office").
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Booking request ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why the booking was lost. Any value outside the list
                    is recorded as `other`; the response's `lost_reason` echoes the
                    value recorded.
                  enum:
                  - out_of_service_area
                  - no_availability
                  - customer_declined
                  - duplicate_request
                  - budget
                  - cancelled_by_customer
                  - other
      responses:
        '200':
          description: The booking request was declined.
          content:
            application/json:
              schema:
                type: object
                required:
                - booking
                - lost_reason
                properties:
                  booking:
                    "$ref": "#/components/schemas/AiFrontOfficeBookingRequest"
                  lost_reason:
                    type: string
                    example: no_availability
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:ai_front_office")
            or `forbidden` (the app is not available to the caller, or the caller
            is not a dispatcher).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`not_found` — "Booking request not found" in this business.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`invalid_state` — the request is not waiting for review or
            approved (already declined, expired, converted, or captured in shadow
            mode); nothing changed. `decline_failed` — the decline could not be saved."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/analytics/sentiment/alerts/{id}/dismiss":
    post:
      tags:
      - Analytics
      summary: Dismiss a sentiment alert
      description: |
        Marks one sentiment alert dismissed, recording who dismissed it, when,
        and the optional reason. Admins, Communications app admins and people in
        a leadership position may dismiss; a manager can read alerts but not dismiss them.

        **Required Scopes:** `destructive:analytics`

        Delete-level (owner decisions 2026-10-04, #6659 / #6721): requires the
        `destructive:analytics` scope; `read:analytics` is not enough. A token
        without it is refused with 403 `insufficient_permissions` ("Required
        scope: destructive:analytics") before the alert is looked up.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Sentiment alert ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Why the alert was dismissed; stored as `dismissed_reason`.
      responses:
        '200':
          description: The alert was dismissed.
          content:
            application/json:
              schema:
                type: object
                required:
                - ok
                - alert
                properties:
                  ok:
                    type: boolean
                    example: true
                  alert:
                    "$ref": "#/components/schemas/SentimentAlert"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` in the standard error envelope
            (the token lacks `destructive:analytics`), or `{ "ok": false, "error":
            "forbidden" }` (no analytics access, or not allowed to dismiss alerts).'
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/AnalyticsRefusal"
        '404':
          description: '`{ "ok": false, "error": "not_found" }` — no such alert in
            this business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/AnalyticsRefusal"
  "/bulk_operations":
    post:
      tags:
      - Bulk Operations
      summary: Queue a bulk operation
      description: |
        Queues one bulk create, update or delete of `items` of one data type and
        answers 201 with the queued operation; the work runs in the background.
        Poll `status_url` (GET /bulk_operations/status/{job_id}) for progress.

        **Token scopes:** by data type — `write:shifts` (shifts, assignments,
        demand_signals, forecasts) or `write:availabilities` (availabilities,
        preferences). `destructive:scheduling` is asked for a final act:
        instead of the write scope for a `delete` of shifts or assignments, and
        on top of it for an assignment set to `cancelled` or `declined` and for
        an overwriting demand-signal or forecast import that sets `active: false`
        (owner decision 2026-10-04, #6721).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - bulk_operation
              - items
              properties:
                bulk_operation:
                  type: object
                  required:
                  - data_type
                  properties:
                    data_type:
                      type: string
                      enum:
                      - shifts
                      - assignments
                      - availabilities
                      - preferences
                      - demand_signals
                      - forecasts
                    operation_type:
                      type: string
                      enum:
                      - create
                      - update
                      - delete
                      default: create
                items:
                  "$ref": "#/components/schemas/BulkOperationItems"
                options:
                  "$ref": "#/components/schemas/BulkOperationOptions"
      responses:
        '201':
          "$ref": "#/components/responses/BulkOperationQueued"
        '400':
          description: "`parameter_missing` — the body has no `bulk_operation` object."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/BulkOperationForbidden"
        '422':
          "$ref": "#/components/responses/BulkOperationRefused"
  "/bulk_operations/shifts":
    post:
      tags:
      - Bulk Operations
      summary: Queue a bulk shift operation
      description: |
        Queues a bulk create, update or delete of shifts. Each item is a shift
        (`id` for an update or a delete); an item's `assignments` are written
        with it.

        **Token scopes:** `write:shifts`, or `destructive:scheduling` instead for
        `operation_type: delete`. An update — or a create with
        `options.conflict_resolution: overwrite` — whose items set an
        assignment's status to `cancelled` or `declined` also needs
        `destructive:scheduling` (owner decision 2026-10-04, #6721).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/BulkOperationTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/BulkOperationQueued"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/BulkOperationForbidden"
        '422':
          "$ref": "#/components/responses/BulkOperationRefused"
  "/bulk_operations/assignments":
    post:
      tags:
      - Bulk Operations
      summary: Queue a bulk shift-assignment operation
      description: |
        Queues a bulk create, update or delete of shift assignments. A create
        item names `shift_id` and `user_id` (an active member of the business);
        an update or a delete names the assignment's `id`.

        **Token scopes:** `write:shifts`, or `destructive:scheduling` instead for
        `operation_type: delete`. An update — or a create with
        `options.conflict_resolution: overwrite` — that sets an assignment's
        `status` to `cancelled` or `declined` also needs `destructive:scheduling`
        (owner decision 2026-10-04, #6721).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/BulkOperationTypedRequest"
      responses:
        '201':
          "$ref": "#/components/responses/BulkOperationQueued"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/BulkOperationForbidden"
        '422':
          "$ref": "#/components/responses/BulkOperationRefused"
  "/bulk_operations/status/{job_id}":
    get:
      tags:
      - Bulk Operations
      summary: Get a bulk operation's progress
      description: |
        The progress of one queued operation — the caller's own, or any in the
        business for an administrator (and, for a Shifts & Scheduling data type,
        a Shifts & Scheduling app admin). Asks no scope beyond a valid token; an
        admin or manager of the business only.
      security:
      - BearerAuth: []
      parameters:
      - name: job_id
        in: path
        required: true
        description: The `job_id` the queueing answer returned.
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: The operation and its progress.
          content:
            application/json:
              schema:
                type: object
                required:
                - bulk_operation
                - progress
                properties:
                  bulk_operation:
                    "$ref": "#/components/schemas/BulkOperationSummary"
                  progress:
                    type: object
                    properties:
                      total:
                        type: integer
                      processed:
                        type: integer
                      successful:
                        type: integer
                      failed:
                        type: integer
                      percentage:
                        type: number
                      duration:
                        type: string
                        nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/BulkOperationForbidden"
        '404':
          description: '`{ "error": "Bulk operation not found" }` — none the caller
            may see.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BulkOperationRefusal"
  "/calendar/events/{id}":
    delete:
      tags:
      - Calendar
      summary: Delete an event
      description: |
        Deletes an event the caller hosts (or hosts as a delegate), through the
        same deletion the web app uses. When the event's online meeting could not
        be cancelled at its provider, the event is still deleted and the answer
        is 200 with `meeting_warning` — the join link may still work.

        An event booked through Appointment Scheduler cannot be deleted here:
        cancel the booking in Appointment Scheduler so the invitee is told (422
        `appointment_backed`; `details.manage_url` links to the booking when the
        caller can open that app, otherwise null).

        **Required Scopes:** `destructive:calendar_sync`

        Delete-level (owner decision 2026-10-04, #6659): requires the
        `destructive:calendar_sync` scope; `write:calendar_sync` is not enough. A
        token without it is refused with 403 `insufficient_permissions`
        ("Required scope: destructive:calendar_sync").
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Calendar event ID
        schema:
          type: integer
      responses:
        '200':
          description: Deleted, but the online meeting's join link could not be cancelled.
          content:
            application/json:
              schema:
                type: object
                required:
                - deleted
                - meeting_warning
                properties:
                  deleted:
                    type: boolean
                    example: true
                  meeting_warning:
                    type: string
        '204':
          description: Deleted
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:calendar_sync"),
            `app_disabled` (the Calendar app is not enabled for the business) or `forbidden`
            (the app is not open to the caller, or "Only the host or their delegate
            may modify this event.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`not_found` — "Event not found." (none in this business, or
            not visible to the caller).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`appointment_backed` (booked through Appointment Scheduler
            — see above) or `destroy_failed` (the event could not be deleted; `details`
            lists the reasons)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/classified_listings":
    get:
      tags:
      - Classifieds
      summary: Browse listings
      description: |
        The live listings Browse shows: active and not past their expiry date,
        newest first unless `sort` says otherwise.

        **Required Scopes:** `read:classified`
      security:
      - BearerAuth: []
      parameters:
      - name: category_id
        in: query
        required: false
        description: Only listings in this category (an id from GET /classified_listings/categories).
          `category_id[]=` takes several.
        schema:
          type: integer
      - name: listing_type
        in: query
        required: false
        description: Only listings of this type; `listing_type[]=` takes several.
          An unknown value is refused with 400 `invalid_parameter`.
        schema:
          type: string
          enum:
          - for_sale
          - free_giveaway
          - wanted_request
          - services_offered
      - name: search
        in: query
        required: false
        description: Words to find in the title, description or tags. Every word must
          match, in any order.
        schema:
          type: string
      - name: sort
        in: query
        required: false
        description: "`newest` (the default), `oldest`, `popular` (most viewed) or
          `favorites` (most favorited). `popular` and `favorites` answer newest first
          while the app's view counter or Favorites is off."
        schema:
          type: string
          enum:
          - newest
          - oldest
          - popular
          - favorites
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Listings per page, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of listings.
          content:
            application/json:
              schema:
                type: object
                required:
                - listings
                - meta
                properties:
                  listings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ClassifiedListing"
                  meta:
                    "$ref": "#/components/schemas/ClassifiedPageMeta"
        '400':
          description: "`invalid_parameter` in the standard error envelope — a nested
            `search`, `listing_type` or `category_id`, or an unknown `listing_type`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
    post:
      tags:
      - Classifieds
      summary: Post a listing
      description: |
        Posts a listing as the caller, under the same rules as the page: it
        starts as `pending_approval` when the app requires approval or its text
        matches an auto-moderation keyword (the moderators are told), otherwise
        `active`. A free giveaway or wanted request is stored with no price.
        Photos are uploaded as files (`classified_listing[photos][]`) in a
        multipart request, within the app's photo count, size and file-type
        settings.

        **Required Scopes:** `write:classified`
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - classified_listing
              properties:
                classified_listing:
                  "$ref": "#/components/schemas/ClassifiedListingInput"
          multipart/form-data:
            schema:
              type: object
              required:
              - classified_listing
              properties:
                classified_listing:
                  "$ref": "#/components/schemas/ClassifiedListingInput"
      responses:
        '201':
          description: Posted; `listing` is the new listing.
          content:
            application/json:
              schema:
                type: object
                required:
                - listing
                properties:
                  listing:
                    "$ref": "#/components/schemas/ClassifiedListingDetail"
        '400':
          description: "`classified_listing` is missing (`parameter_missing`) or is
            not an object (`invalid_parameter`), in the standard error envelope."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
        '422':
          description: Refused. `errors` lists why (a field the app requires, a category
            it does not allow, the posting limit, `tags` sent as a list); a photo
            of a type or size the app refuses answers `invalid_photos` in the standard
            error envelope instead.
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/ClassifiedErrors"
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
  "/classified_listings/my_listings":
    get:
      tags:
      - Classifieds
      summary: List my listings
      description: |
        The caller's own listings in every state, newest first — live, awaiting
        approval, fulfilled, expired and removed. `status_text` says which kind
        of removal a `deleted` listing is (their own delete, a moderator's
        takedown, or a rejection with the moderator's reason).

        **Required Scopes:** `read:classified`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Listings per page, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of the caller's listings.
          content:
            application/json:
              schema:
                type: object
                required:
                - listings
                - meta
                properties:
                  listings:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ClassifiedMyListing"
                  meta:
                    "$ref": "#/components/schemas/ClassifiedPageMeta"
        '400':
          description: "`invalid_parameter` in the standard error envelope — a nested
            `search`, `listing_type` or `category_id` in the query."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
  "/classified_listings/my_favorites":
    get:
      tags:
      - Classifieds
      summary: List my favorites
      description: |
        The listings the caller favorited that they can still see, most
        recently favorited first. A removed listing is left out, and so is one
        awaiting approval unless it is the caller's or the caller moderates
        Classifieds.

        **Required Scopes:** `read:classified`
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: per_page
        in: query
        required: false
        description: Favorites per page, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of favorited listings.
          content:
            application/json:
              schema:
                type: object
                required:
                - favorites
                - meta
                properties:
                  favorites:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ClassifiedListing"
                  meta:
                    "$ref": "#/components/schemas/ClassifiedPageMeta"
        '400':
          description: "`invalid_parameter` in the standard error envelope — a nested
            `search`, `listing_type` or `category_id` in the query."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
        '422':
          description: '`{ "error": "Favorites are not enabled." }` — the app''s Favorites
            setting is off.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClassifiedsRefusal"
  "/classified_listings/categories":
    get:
      tags:
      - Classifieds
      summary: List categories
      description: |
        The app's active categories, in the order the page lists them — the
        ids `category_id` filters by and a new listing is posted under.
        `postable` is false for a category the app's prohibited categories
        setting refuses for new listings; it stays listed so older listings in
        it can still be filtered to.

        **Required Scopes:** `read:classified`
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The categories.
          content:
            application/json:
              schema:
                type: object
                required:
                - categories
                properties:
                  categories:
                    type: array
                    items:
                      "$ref": "#/components/schemas/ClassifiedCategory"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
  "/classified_listings/{id}":
    get:
      tags:
      - Classifieds
      summary: Get a listing
      description: |
        One listing with its description, contact details and what the caller
        may do with it (`can_edit`, `can_delete`). A listing awaiting approval
        is found only by its seller and by a business admin or Classifieds app
        admin. Opening someone else's listing counts a view when the app's view
        counter is on.

        **Required Scopes:** `read:classified`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Listing ID
        schema:
          type: integer
      responses:
        '200':
          description: The listing.
          content:
            application/json:
              schema:
                type: object
                required:
                - listing
                properties:
                  listing:
                    "$ref": "#/components/schemas/ClassifiedListingDetail"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
        '404':
          description: '`{ "error": "Listing not found." }` — none in this business,
            removed, or awaiting approval and not the caller''s to see.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClassifiedsRefusal"
    patch:
      tags:
      - Classifieds
      summary: Edit a listing
      description: |
        Changes a listing that is live or awaiting approval; send only the
        fields that change (PUT is accepted too). Its seller may edit it, and a
        business admin or Classifieds app admin may edit anyone's. New text
        goes back for approval when the app requires approval or
        matches an auto-moderation keyword; a lower price tells the people who
        favorited it. Photos sent as files (`classified_listing[photos][]`) are
        added to the ones it has; when they would pass the app's photo limit
        the edit is still saved and `warnings` says why the photos were not.

        **Required Scopes:** `write:classified`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Listing ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - classified_listing
              properties:
                classified_listing:
                  "$ref": "#/components/schemas/ClassifiedListingInput"
          multipart/form-data:
            schema:
              type: object
              required:
              - classified_listing
              properties:
                classified_listing:
                  "$ref": "#/components/schemas/ClassifiedListingInput"
      responses:
        '200':
          description: Saved; `listing` is the listing as it now stands.
          content:
            application/json:
              schema:
                type: object
                required:
                - listing
                properties:
                  listing:
                    "$ref": "#/components/schemas/ClassifiedListingDetail"
                  warnings:
                    type: array
                    description: Present when the edit saved but the new photos were
                      refused.
                    items:
                      type: string
        '400':
          description: "`classified_listing` is missing (`parameter_missing`) or is
            not an object (`invalid_parameter`), in the standard error envelope."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`{ "error": "You cannot edit this listing." }` (not the caller''s
            to edit, or fulfilled), `{ "error": "This listing has expired. Renew it
            to edit it again." }` (an expired listing the caller may renew: `PATCH
            /classified_listings/{id}/extend_expiry` first), a scope refusal, or the
            app is not enabled or not open to the caller.'
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
        '404':
          description: '`{ "error": "Listing not found." }`'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClassifiedsRefusal"
        '422':
          description: Refused. `errors` lists why; a photo of a type or size the
            app refuses answers `invalid_photos` in the standard error envelope instead,
            before anything is changed.
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/ClassifiedErrors"
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
    delete:
      tags:
      - Classifieds
      summary: Remove a listing
      description: |
        Removes a listing: its seller may remove their own, and a business admin
        or Classifieds app admin may remove anyone's. Such a removal is recorded
        as a moderator's, the seller is notified, and the listing's open reports
        are resolved. Removing
        someone else's listing with a token also needs `write:classified` on the
        token.

        **Required Scopes:** `destructive:classified`

        Delete-level (owner decision 2026-10-04, #6659): requires the
        `destructive:classified` scope; `write:classified` alone is not enough.
        A token without it is refused with 403 `insufficient_permissions`
        ("Required scope: destructive:classified").
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Listing ID
        schema:
          type: integer
      responses:
        '200':
          description: The listing was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - message
                properties:
                  message:
                    type: string
                    example: Listing deleted.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` in the standard error envelope
            ("Required scope: destructive:classified"), or `{ "error": "…" }` — the
            app is not enabled or not open to the caller, or the caller may not remove
            this listing.'
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
        '404':
          description: '`{ "error": "Listing not found." }` — none in this business,
            or already removed.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClassifiedsRefusal"
        '422':
          description: The removal could not be saved; `errors` lists why.
          content:
            application/json:
              schema:
                type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: string
  "/classified_listings/{id}/toggle_favorite":
    patch:
      tags:
      - Classifieds
      summary: Favorite or unfavorite a listing
      description: |
        Favorites the listing, or unfavorites it when the caller already has.
        A seller cannot favorite their own listing.

        **Required Scopes:** `write:classified`
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Listing ID
        schema:
          type: integer
      responses:
        '200':
          description: The caller's favorite as it now stands.
          content:
            application/json:
              schema:
                type: object
                required:
                - favorited
                - favorites_count
                properties:
                  favorited:
                    type: boolean
                    description: True when the listing is now among the caller's favorites.
                  favorites_count:
                    type: integer
                    description: How many people have favorited the listing.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: A scope refusal, or the app is not enabled or not open to the
            caller.
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/ClassifiedsRefusal"
        '404':
          description: '`{ "error": "Listing not found." }` — none in this business,
            removed, or awaiting approval and not the caller''s to see.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClassifiedsRefusal"
        '422':
          description: '`{ "error": "Favorites are not enabled." }` (the app''s Favorites
            setting is off) or `{ "error": "You can''t favorite your own listing."
            }`.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ClassifiedsRefusal"
  "/classified_listings/{id}/fulfill":
    patch:
      tags:
      - Classifieds
      summary: Mark a listing fulfilled
      description: |
        Marks a live listing fulfilled (the page's **Mark as Fulfilled**): it
        comes off Browse and cannot be reopened. People who saved it are told;
        when a business admin or Classifieds app admin closes someone else's
        listing, its owner is told too. Closing someone else's listing with a
        token also needs `write:classified` on the token.

        **Required Scopes:** `destructive:classified`

        Delete-level (owner decisions 2026-10-04, #6659 and #6721): it cannot
        be undone, so it asks `destructive:classified` — the scope MCP and Ask
        AI ask to fulfil a listing — and `write:classified` alone is not
        enough. A token without it is refused with 403
        `insufficient_permissions` ("Required scope: destructive:classified").
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: fulfillment_notes
        in: query
        required: false
        description: An optional public note shown on the listing ("Sold to a coworker").
        schema:
          type: string
      responses:
        '200':
          description: Marked fulfilled; `listing` is the updated listing.
        '403':
          description: '`{ "error": "You cannot mark this listing as fulfilled." }`,
            or a scope refusal ("Required scope: destructive:classified").'
        '404':
          description: '`{ "error": "Listing not found." }`'
        '422':
          description: The change could not be saved; `errors` lists why.
  "/classified_listings/{id}/extend_expiry":
    patch:
      tags:
      - Classifieds
      summary: Renew a listing
      description: |
        Renews a listing (the page's **Renew**): its expiry is set again,
        counted from today, by the app's Auto-expire Listings setting. Allowed
        for an expired listing or one within 7 days of expiring (a quarter of
        the Auto-expire window when that is under 25 days). Renewing an
        expired listing counts against the posting limit, and is refused when
        the seller already has that many listings live or awaiting approval,
        or has left the organization.

        **Required Scopes:** `write:classified`
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Renewed; `expires_at` is the new expiry.
        '403':
          description: '`{ "error": "<why this listing can''t be renewed>" }`, or
            a scope refusal.'
        '404':
          description: '`{ "error": "Listing not found." }`'
        '422':
          description: Refused while saving (for example the posting limit); `errors`
            lists why.
  "/mango_signal/score":
    get:
      tags:
      - Mango Signal
      summary: Company engagement score
      description: |
        The newest company-wide engagement score. Every value is null when no
        score has been computed yet, and when the business switched the score
        off (`engagement_score_enabled: false`). Any member who can open Mango
        Signal may read it.

        **Token scopes:** `read:mango_signal`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The score.
          content:
            application/json:
              schema:
                type: object
                properties:
                  engagement_score_enabled:
                    type: boolean
                  score:
                    type: number
                    nullable: true
                  stale:
                    type: boolean
                    description: The score's window ended before the current window
                      began — it is the last score, not a current one.
                  computed_at:
                    type: string
                    format: date-time
                    nullable: true
                  window_start:
                    type: string
                    format: date-time
                    nullable: true
                  window_end:
                    type: string
                    format: date-time
                    nullable: true
                  signal_count:
                    type: integer
                    nullable: true
                  breakdown:
                    type: object
                    nullable: true
                    additionalProperties:
                      type: number
                    description: Average score per signal type.
                  is_sample_data:
                    type: boolean
                    nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
  "/mango_signal/themes":
    get:
      tags:
      - Mango Signal
      summary: Top feedback themes
      description: |
        The most frequent themes in the window's signals. A theme backed by
        fewer people than the business's anonymity floor is left out. Any
        member who can open Mango Signal may read it.

        **Token scopes:** `read:mango_signal`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MangoSignalAggregateWindowDays"
      - name: limit
        in: query
        required: false
        description: Themes to return, 1–200 (default 10).
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 10
      responses:
        '200':
          description: The themes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  window_days:
                    type: integer
                    description: The window applied.
                  sample_data_included:
                    type: integer
                    description: How many sample-data signals the window holds.
                  themes:
                    type: array
                    items:
                      type: object
                      properties:
                        label:
                          type: string
                        polarity:
                          type: string
                          nullable: true
                        count:
                          type: integer
                        avg_score:
                          type: number
                          nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
  "/mango_signal/risk_segments":
    get:
      tags:
      - Mango Signal
      summary: Groups at risk
      description: |
        Locations, teams and segments whose engagement score is below the
        threshold, worst first. A group smaller than the business's anonymity
        floor (`min_group_size`) is never listed. Managers and admins only
        (403 `manager_access_required`).

        **Token scopes:** `read:mango_signal`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MangoSignalAggregateWindowDays"
      - name: threshold
        in: query
        required: false
        description: Score below which a group is at risk, 0–1 (clamped). Defaults
          to the business's configured threshold, as does a value that is not a number.
        schema:
          type: number
          minimum: 0
          maximum: 1
      - name: limit
        in: query
        required: false
        description: Segments to return, 1–200 (default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 25
      responses:
        '200':
          description: The segments.
          content:
            application/json:
              schema:
                type: object
                properties:
                  threshold:
                    type: number
                  min_group_size:
                    type: integer
                  window_days:
                    type: integer
                  sample_data_included:
                    type: integer
                  truncated:
                    type: boolean
                    description: More segments matched than `limit` returned.
                  segments:
                    type: array
                    items:
                      type: object
                      properties:
                        scope_type:
                          type: string
                          enum:
                          - location
                          - team
                          - segment
                        scope_id:
                          type: integer
                          nullable: true
                        segment_key:
                          type: string
                          nullable: true
                        score:
                          type: number
                        signal_count:
                          type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
  "/mango_signal/signals":
    get:
      tags:
      - Mango Signal
      summary: The signals feed
      description: |
        Individual signals, newest first. Each row can name the person it is
        about (`user_id`), so this feed is for the people the business lets
        read individual signals — by default Mango Signal and business admins
        (403 `admin_access_required` otherwise). Free-text payloads are never
        returned, and every read is recorded in the app's access log.

        Page through the feed with `offset` and `limit`; `total` is the size
        of the filtered set, `count` the rows in this response.

        **Token scopes:** `read:mango_signal`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MangoSignalRowWindowDays"
      - name: source_app
        in: query
        required: false
        description: Only signals from this source app (repeat as `source_app[]` for
          several). A value that is not a string or an array of strings is refused
          (422 `invalid_source_app`).
        schema:
          type: string
      - name: signal_type
        in: query
        required: false
        description: Only signals of this type (repeat as `signal_type[]` for several;
          422 `invalid_signal_type` as above).
        schema:
          type: string
      - name: segment_key
        in: query
        required: false
        description: Only signals filed under this segment — its key, or its name
          as the dashboard shows it (repeat as `segment_key[]` for several, at most
          50; 422 `invalid_segment_key` as above or above 50 values).
        schema:
          type: string
      - name: limit
        in: query
        required: false
        description: Rows to return, 1–200 (default 50).
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 50
      - name: offset
        in: query
        required: false
        description: Rows to skip before this page (default 0).
        schema:
          type: integer
          minimum: 0
          default: 0
      responses:
        '200':
          description: The signals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  window_days:
                    type: integer
                    nullable: true
                    description: The window applied; null when none was asked for.
                  count:
                    type: integer
                    description: Rows in this response.
                  total:
                    type: integer
                    description: Signals matching the filters
                    before `offset` and `limit`.:
                  offset:
                    type: integer
                  segment_key:
                    nullable: true
                    description: The segment filter applied (a key, or an array of
                      keys).
                    oneOf:
                    - type: string
                    - type: array
                      items:
                        type: string
                  signals:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        source_app:
                          type: string
                        signal_type:
                          type: string
                        score:
                          type: number
                          nullable: true
                        occurred_at:
                          type: string
                          format: date-time
                        dated_only:
                          type: boolean
                          description: The signal records a day only; `occurred_at`
                            is a placeholder time.
                        occurred_on:
                          type: string
                          format: date
                          description: The day
                          on the business's calendar.:
                        user_id:
                          type: integer
                          nullable: true
                        team_id:
                          type: integer
                          nullable: true
                        location_id:
                          type: integer
                          nullable: true
                        segment_key:
                          type: string
                          nullable: true
                        is_sample_data:
                          type: boolean
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
        '422':
          description: A filter was sent in a shape that cannot be applied (`invalid_source_app`,
            `invalid_signal_type`, `invalid_segment_key`); nothing was filtered.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalRefusal"
  "/mango_signal/actions":
    get:
      tags:
      - Mango Signal
      summary: List actions
      description: |
        Actions, soonest due first. A manager or admin sees every action; any
        other member sees the actions they own or are assigned.

        **Token scopes:** `read:mango_signal`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/MangoSignalRowWindowDays"
      - name: status
        in: query
        required: false
        description: One status, or `open_or_in_progress` for both live statuses.
          Any other value is refused (422 `invalid_status`).
        schema:
          type: string
          enum:
          - open
          - in_progress
          - complete
          - dismissed
          - open_or_in_progress
      - name: assigned_to_me
        in: query
        required: false
        description: Only actions assigned to the caller.
        schema:
          type: boolean
      - name: limit
        in: query
        required: false
        description: Rows to return, 1–200 (default 25).
        schema:
          type: integer
          minimum: 1
          maximum: 200
          default: 25
      responses:
        '200':
          description: The actions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  window_days:
                    type: integer
                    nullable: true
                    description: Actions created in this many days; null when none
                      was asked for.
                  count:
                    type: integer
                  actions:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MangoSignalAction"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
        '422':
          description: "`invalid_status` — an unknown `status` filter."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalRefusal"
    post:
      tags:
      - Mango Signal
      summary: Raise an action
      description: |
        Creates an action owned by the caller. Managers and admins only (403
        `manager_access_required`).

        A new action starts `open` or `in_progress`; `complete` or `dismissed`
        is refused (422 `invalid_status`) — create it, then complete or dismiss
        it with PATCH, so its "you said, we did" update and its impact
        measurement run.

        The fields may be sent at the top level of the body or wrapped in
        `mango_signal_action` or `action`. An `Idempotency-Key` header makes a
        retry return the first success instead of writing again.

        **Token scopes:** `write:mango_signal`.
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: A key you choose per write; a retry with the same key replays
          the stored success.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - title
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: string
                status:
                  type: string
                  enum:
                  - open
                  - in_progress
                  default: open
                assignee_id:
                  type: integer
                  description: Defaults to the caller. Must be an active member of
                    the business (422 `assignee_not_found`).
                due_at:
                  type: string
                  description: The day it is due (`YYYY-MM-DD`); stored as that calendar
                    day.
                scope_type:
                  type: string
                  enum:
                  - company
                  - location
                  - segment
                scope_id:
                  type: integer
                  description: The location, for a `location` scope. An id outside
                    the caller's business is dropped and reported in `warnings`.
                segment_key:
                  type: string
                  description: The segment
                  for a `segment` scope.:
                ai_recommended:
                  type: boolean
                signal_ids:
                  type: array
                  maxItems: 1000
                  items:
                    type: integer
                  description: The signals this action answers. More than 1,000 is
                    refused with 422 `too_many_signal_ids`.
      responses:
        '201':
          description: The action was created.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalAction"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
        '422':
          description: '`invalid_status` (a new action cannot start complete or dismissed),
            `assignee_not_found`, `too_many_signal_ids`, or `{ "errors": [...] }`
            when the action fails validation.'
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/MangoSignalRefusal"
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: string
  "/mango_signal/actions/{id}":
    get:
      tags:
      - Mango Signal
      summary: Get an action
      description: |
        One action, with its description, completion notes and impact. A
        manager or admin may read any action; any other member only one they
        own or are assigned (403 `manager_access_required`, with a message
        saying who can open it).

        **Token scopes:** `read:mango_signal`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Action ID
        schema:
          type: integer
      responses:
        '200':
          description: The action.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalAction"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          "$ref": "#/components/responses/MangoSignalForbidden"
        '404':
          description: "`not_found` — no such action in this business."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalRefusal"
    patch:
      tags:
      - Mango Signal
      summary: Update an action
      description: |
        Updates an action. Its owner, a business admin and a Mango Signal app
        admin may change any field; its assignee may only move `status` among
        `open`, `in_progress` and `complete` and write `completion_notes` (other
        fields are ignored, and any other status is refused with 422
        `status_not_permitted`). Dismissing an action is therefore its owner's
        or an admin's decision.

        A completed or dismissed action is closed: this endpoint refuses any
        write to it (422 `action_not_actionable`, with a message saying who can
        reopen it from the web app).

        Completing an action (`status` moving to `complete`) runs the "you
        said, we did" follow-up; `close_loop` in the response reports whether it
        went out (`fired`, `fired_without_impact_baseline` or `not_fired`).

        The fields may be sent at the top level of the body or wrapped in
        `mango_signal_action` or `action`. An `Idempotency-Key` header makes a
        retry return the first success instead of writing again.

        **Token scopes:** `write:mango_signal`, and also
        `destructive:mango_signal` when `status` is `dismissed` — dismissing an
        action is a final act (owner decision 2026-10-04, #6721). Any other
        update needs only the write scope.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Action ID
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: A key you choose per write; a retry with the same key replays
          the stored success.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum:
                  - open
                  - in_progress
                  - complete
                  - dismissed
                  description: "`dismissed` also needs `destructive:mango_signal`,
                    and only the owner or an admin may set it."
                completion_notes:
                  type: string
                announce_company_wide:
                  type: boolean
                  description: 'Read only when this request completes the action:
                    also post the update to everyone, for an action whose scope has
                    no people list.'
                title:
                  type: string
                  description: Owner or admin only.
                description:
                  type: string
                  description: Owner or admin only.
                assignee_id:
                  type: integer
                  description: Owner or admin only. Must be an active member of the
                    business (422 `assignee_not_found`).
                due_at:
                  type: string
                  description: Owner or admin only. The day it is due (`YYYY-MM-DD`);
                    stored as that calendar day.
                scope_type:
                  type: string
                  description: Owner or admin only.
                scope_id:
                  type: integer
                  description: Owner or admin only. An id outside the caller's business
                    is dropped and reported in `warnings`.
                segment_key:
                  type: string
                  description: Owner or admin only.
                ai_recommended:
                  type: boolean
                  description: Owner or admin only.
                signal_ids:
                  type: array
                  maxItems: 1000
                  items:
                    type: integer
                  description: 'Owner or admin only: the signals this action answers.
                    More than 1,000 is refused with 422 `too_many_signal_ids`.'
      responses:
        '200':
          description: The action was updated.
          content:
            application/json:
              schema:
                type: object
                description: The action. `scope_type`, `segment_key`, `scope_id` (null
                  with a `scope_label` for an action about one individual) and `signal_ids`
                  appear only for a manager-tier caller.
                properties:
                  id:
                    type: integer
                  title:
                    type: string
                  status:
                    type: string
                  assignee_id:
                    type: integer
                    nullable: true
                  assignee_name:
                    type: string
                    nullable: true
                  assignee_active:
                    type: boolean
                  owner_id:
                    type: integer
                  owner_name:
                    type: string
                    nullable: true
                  owner_active:
                    type: boolean
                  due_at:
                    type: string
                    format: date-time
                    nullable: true
                  due_date:
                    type: string
                    format: date
                    nullable: true
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  ai_recommended:
                    type: boolean
                  created_at:
                    type: string
                    format: date-time
                  is_sample_data:
                    type: boolean
                  description:
                    type: string
                    nullable: true
                  completion_notes:
                    type: string
                    nullable: true
                  source_survey:
                    type: object
                    nullable: true
                  impact:
                    type: object
                    nullable: true
                  scope_type:
                    type: string
                  segment_key:
                    type: string
                    nullable: true
                  scope_id:
                    type: integer
                    nullable: true
                  scope_label:
                    type: string
                  signal_ids:
                    type: array
                    items:
                      type: integer
                  close_loop:
                    type: string
                    enum:
                    - fired
                    - fired_without_impact_baseline
                    - not_fired
                    description: Present only when this request completed the action.
                  warnings:
                    type: array
                    items:
                      type: string
                    description: Present only when a submitted `scope_id` was dropped.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`insufficient_permissions` (`code`; `error` names the missing
            scope — `destructive:mango_signal` for a dismissal), `mango_signal_disabled`,
            or `action_access_required` (not the owner, the assignee or an admin)."
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalRefusal"
        '404':
          description: '`{ "error": "not_found" }` — no such action in this business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSignalRefusal"
        '422':
          description: '`action_not_actionable` (the action is complete or dismissed),
            `status_not_permitted` (an assignee asked for a status they may not set),
            `assignee_not_found`, `too_many_signal_ids`, or `{ "errors": [...] }`
            when the action fails validation.'
          content:
            application/json:
              schema:
                oneOf:
                - "$ref": "#/components/schemas/MangoSignalRefusal"
                - type: object
                  properties:
                    errors:
                      type: array
                      items:
                        type: string
  "/mango_spend/summary":
    get:
      tags:
      - Mango Spend
      summary: Get your Mango Spend summary
      description: |
        The caller's spend roll-up: their drafts, reports in flight, returned
        claims and paid-this-year totals. An approver also gets what is waiting
        for their decision, and a caller who may view all spend also gets the
        company's payout figures (see the response fields).

        **Required Scopes:** `read:mango_spend` (`read:own_mango_spend` also
        passes, but then the approver and company blocks are left out — only the
        personal block is answered).

        Needs Reimbursements switched on for the business.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The summary.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MangoSpendSummary"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/expense_reports":
    get:
      tags:
      - Mango Spend
      summary: List expense reports
      description: |
        Expense reports the caller may see, most recently updated first. That is
        the caller's own reports plus those the app's visibility rule opens to
        them (the filed reports of the people they manage; every filed report
        for a Mango Spend admin); other people's drafts are never listed. A token
        holding only `read:own_mango_spend` sees only the caller's own reports.

        **Required Scopes:** `read:mango_spend` (`read:own_mango_spend` also
        passes).

        Needs Reimbursements switched on for the business. Paginated: the
        `reports` key is unchanged and `meta` plus the `X-Total-Count`,
        `X-Total-Pages`, `X-Current-Page` and `X-Per-Page` headers describe the
        whole filtered set.
      security:
      - BearerAuth: []
      parameters:
      - name: status[]
        in: query
        required: false
        description: Only reports in these statuses. Repeat it (`status[]=draft&status[]=submitted`)
          to take several; one status may also be sent as a plain `status=draft`.
          Repeating the plain `status=` keeps only the last value. A value that is
          not a status matches nothing (an empty list), it does not widen the list.
        schema:
          type: array
          items:
            type: string
            enum:
            - draft
            - submitted
            - approved
            - rejected
            - sent_back
            - paid
            - cancelled
        style: form
        explode: true
      - name: page
        in: query
        required: false
        description: 1-based page number. Anything that is not a positive number means
          the first page.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: limit
        in: query
        required: false
        description: Reports per page. Anything that is not a positive number means
          25; more than 100 is cut to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: One page of reports (the short form, without lines).
          headers:
            X-Total-Count:
              description: Reports in the whole filtered set.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages in the whole filtered set.
              schema:
                type: integer
            X-Current-Page:
              description: The page returned.
              schema:
                type: integer
            X-Per-Page:
              description: Reports per page as applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - reports
                - meta
                properties:
                  reports:
                    type: array
                    items:
                      "$ref": "#/components/schemas/MangoSpendExpenseReport"
                  meta:
                    "$ref": "#/components/schemas/MangoSpendPaginationMeta"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    post:
      tags:
      - Mango Spend
      summary: Start an expense report
      description: |
        Creates a draft report owned by the caller. Add lines with
        `POST /mango_spend/expense_reports/{id}/lines` and file it with
        `POST /mango_spend/expense_reports/{id}/submit`.

        **Required Scopes:** `write:mango_spend` (`write:own_mango_spend` also
        passes — the report is the caller's own).

        Needs Reimbursements switched on for the business.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - expense_report
              properties:
                expense_report:
                  type: object
                  required:
                  - title
                  properties:
                    title:
                      type: string
                      maxLength: 200
                      description: Required.
                    purpose:
                      type: string
                      nullable: true
                    currency:
                      type: string
                      description: Three-letter currency code (USD, EUR, GBP, CAD
                        or AUD). Used only when the business has multi-currency on;
                        otherwise, and when omitted or not one of those five, the
                        report is filed in the business's default currency — silently,
                        with no error. Every line the report gets is stamped with
                        the report's currency.
                    report_period_start:
                      type: string
                      format: date
                      nullable: true
                      description: Not in the future.
                    report_period_end:
                      type: string
                      format: date
                      nullable: true
                      description: Not in the future, and not before `report_period_start`.
      responses:
        '201':
          description: The draft report (detailed form).
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                properties:
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
        '400':
          description: "`parameter_missing` — no `expense_report` object in the body
            (or it is not an object)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`invalid_report` — the report failed validation (no title,
            a title over 200 characters, a future or backwards period); the message
            lists the problems."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/expense_reports/{id}":
    get:
      tags:
      - Mango Spend
      summary: Get an expense report
      description: |
        One report in the detailed form: its purpose, lines (each with its
        receipts) and policy violations. Opens the caller's own report, and any
        report the caller may read as its approver or a Mango Spend admin; a
        token holding only `read:own_mango_spend` opens only the caller's own.

        **Required Scopes:** `read:mango_spend` (`read:own_mango_spend` also
        passes).

        With Reimbursements switched off the caller's own reports (and drafts)
        answer 403 `reimbursements_disabled`; a filed report that someone else
        owns and the caller may read still opens, so an approver can open the
        report they are deciding.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID
        schema:
          type: integer
      responses:
        '200':
          description: The report (detailed form).
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                properties:
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such report, or one the caller may not read."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/expense_reports/{id}/submit":
    post:
      tags:
      - Mango Spend
      summary: Submit your expense report
      description: |
        Files one of the caller's own reports for approval. The report's policy
        rules are checked first; a blocking violation (a line with no amount, a
        missing receipt the policy requires) refuses the submit and is listed in
        `error.details.violations`. On success the report moves to `submitted`,
        its approval chain starts and the approver is told.

        **Required Scopes:** `write:mango_spend` (`write:own_mango_spend` also
        passes).

        Needs Reimbursements switched on for the business. Takes no request body.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID (one of the caller's own)
        schema:
          type: integer
      responses:
        '200':
          description: The submitted report (detailed form).
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                properties:
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such report of the caller's."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: '`policy_blocked` ("Resolve blocking policy violations before
            submitting."; `error.details.violations` lists them as MangoSpendPolicyViolation
            objects) or `illegal_transition` (the report cannot move to submitted
            from its current status, or another rule refuses it — the message says
            which).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/expense_reports/{id}/lines":
    post:
      tags:
      - Mango Spend
      summary: Add a line to your expense report
      description: |
        Adds one line (an expense, a mileage claim or a per-diem day) to one of
        the caller's own reports while it is still open for editing (`draft` or
        `sent_back`). The line takes the report's currency; a `currency` in the
        body is ignored. Mileage and per-diem lines are priced from the
        business's rates — a rate cannot be supplied.

        **Required Scopes:** `write:mango_spend` (`write:own_mango_spend` also
        passes).

        Needs Reimbursements switched on for the business.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID (one of the caller's own)
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - line
              properties:
                line:
                  "$ref": "#/components/schemas/MangoSpendLineInput"
      responses:
        '201':
          description: The new line.
          content:
            application/json:
              schema:
                type: object
                required:
                - line
                properties:
                  line:
                    "$ref": "#/components/schemas/MangoSpendExpenseLine"
        '400':
          description: "`parameter_missing` — no `line` object in the body (or it
            is not an object)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"),
            `app_not_enabled`, `app_access_denied`, `reimbursements_disabled`, `mileage_disabled`
            (a mileage line while Mileage is switched off in Settings) or `per_diem_disabled`
            (a per-diem line while Per-diem rates are switched off).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such report of the caller's."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: '`report_not_editable` ("This report is <status> and can no
            longer be changed.") or `invalid_line` (the line failed validation — an
            expense with no amount, a mileage line with no miles, a per-diem line
            with no days or `location_code`, an unknown `line_type`, a future `spent_on`
            on an expense, an archived category; the message lists the problems).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/expense_reports/{id}/receipts":
    post:
      tags:
      - Mango Spend
      summary: Attach a receipt to your expense report
      description: |
        Uploads a receipt (an image or PDF) to one of the caller's own reports
        while it is still open for editing. With `line_id` the receipt goes on
        that line; without it a placeholder expense line (`needs_amount: true`,
        $0, today's date) is made for the receipt reader to fill — the report
        cannot be submitted until that line has an amount, so price it with
        `PATCH /mango_spend/expense_reports/{id}/lines/{line_id}` or remove it
        if the reading fails. The file's type, size and actual content are
        checked; a file that is not a receipt is refused.

        **Required Scopes:** `write:mango_spend` (`write:own_mango_spend` also
        passes).

        Needs Reimbursements switched on for the business.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID (one of the caller's own)
        schema:
          type: integer
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The receipt, as a multipart file part. A plain text
                    value is refused (422 `invalid_file`).
                line_id:
                  type: integer
                  description: 'Attach to this line of the report. Omitted: a placeholder
                    line is made. A line that is not on this report answers 404.'
      responses:
        '201':
          description: The stored receipt and the line it is on.
          content:
            application/json:
              schema:
                type: object
                required:
                - receipt
                - line
                properties:
                  receipt:
                    type: object
                    properties:
                      id:
                        type: integer
                      line_id:
                        type: integer
                      extraction_status:
                        type: string
                        description: Where the receipt reader is with the file, `pending`
                          when just stored.
                        example: pending
                  line:
                    "$ref": "#/components/schemas/MangoSpendExpenseLine"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such report of the caller's, or no such line
            on it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: '`report_not_editable`; `missing_file` ("A file upload is required.");
            `invalid_file` (`file` is not a multipart upload, or its type, size or
            content is not an acceptable receipt — the message says why); or `invalid_receipt`
            (the receipt could not be stored). Nothing is left behind on a failure
            — no placeholder line.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/approvals":
    get:
      tags:
      - Mango Spend
      summary: List reports waiting for your decision
      description: |
        The approver's queue, oldest submission first: the submitted reports
        waiting on the caller (as the submitter's manager or a named approver in
        an approval chain). A Mango Spend admin sees every submitted report in
        the business except their own. Each row is the short report form plus
        `employee` — the submitter's name, else their email.

        **Required Scopes:** `read:mango_spend` — the wide grant; a
        `read:own_mango_spend` token is refused, because the queue holds other
        people's reports.

        Only people who approve reports have a queue: a Mango Spend admin,
        someone with direct reports, or anyone named in an approval chain.
        Everyone else gets 403 `not_a_manager`. Reachable with Reimbursements
        switched off. Paginated like the report list.
      security:
      - BearerAuth: []
      parameters:
      - name: page
        in: query
        required: false
        description: 1-based page number. Anything that is not a positive number means
          the first page.
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: limit
        in: query
        required: false
        description: Reports per page. Anything that is not a positive number means
          25; more than 100 is cut to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 25
      responses:
        '200':
          description: One page of the queue.
          headers:
            X-Total-Count:
              description: Reports in the whole queue.
              schema:
                type: integer
            X-Total-Pages:
              description: Pages in the whole queue.
              schema:
                type: integer
            X-Current-Page:
              description: The page returned.
              schema:
                type: integer
            X-Per-Page:
              description: Reports per page as applied.
              schema:
                type: integer
          content:
            application/json:
              schema:
                type: object
                required:
                - reports
                - meta
                properties:
                  reports:
                    type: array
                    items:
                      allOf:
                      - "$ref": "#/components/schemas/MangoSpendExpenseReport"
                      - type: object
                        properties:
                          employee:
                            type: string
                            nullable: true
                            description: The submitter's name, else their email.
                  meta:
                    "$ref": "#/components/schemas/MangoSpendPaginationMeta"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: read:mango_spend"
            or "Required scope: read:mango_spend (read:own_mango_spend only covers
            your own reports)."), `app_not_enabled`, `app_access_denied` or `not_a_manager`
            (the caller approves no one''s reports).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/approvals/{id}/approve":
    post:
      tags:
      - Mango Spend
      summary: Approve an expense report
      description: |
        Approves the approval step the caller holds on a submitted expense
        report (a Mango Spend app admin may decide any submitted report except
        their own). When the report has an approval chain with more steps, the
        decision advances it to the next approver (`approval_chain_completed`
        false, the report stays `submitted`); the final approval ends the chain
        and the report becomes `approved`. The submitter, or the next approver,
        is told.

        **Token scopes:** `write:mango_spend` — deciding another employee's
        report needs the wide write scope (`write:own_mango_spend` covers only
        your own reports, and you cannot decide your own). Approving is not
        delete-level, so `destructive:mango_spend` is not asked (rejecting is).
        A token missing it is refused with 403 `insufficient_permissions`.

        Reachable with Reimbursements switched off.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  maxLength: 2000
                  description: An optional note stored with the decision. Trimmed
                    and cut to 2,000 characters; a non-string value counts as none.
      responses:
        '200':
          description: The decision was recorded.
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                - approval_chain_completed
                properties:
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
                  approval_chain_completed:
                    type: boolean
                    description: Whether the decision ended the report's approval
                      chain.
                  decided_in_place_of:
                    type: string
                    nullable: true
                    description: 'Set when a Mango Spend admin decided a report that
                      was waiting on someone else (its manager or a chain step): who
                      was skipped, in the sentence the review page shows before the
                      decision. Null when the report was the caller''s to decide.'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"
            or "Required scope: write:mango_spend (write:own_mango_spend cannot decide
            another employee''s report)."), `app_not_enabled` or `app_access_denied`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — the report is not waiting for the caller's decision
            (a report of the caller's own never is). When it was taken back to draft,
            or already decided by someone else, the message says so."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`stale_decision` — this approval step was already decided,
            or another approver decided (or the submitter took back) the report in
            the same moment. In the second case the message says who; in the first
            it says only that the step was already decided."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`illegal_transition` — it is waiting for a different approver,
            or it cannot move to approved from its current status."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/approvals/{id}/reject":
    post:
      tags:
      - Mango Spend
      summary: Reject an expense report
      description: |
        Rejects the approval step the caller holds on a submitted expense report
        (a Mango Spend app admin may decide any submitted report except their
        own). The submitter is told; the report ends `rejected`.

        **Token scopes:** `destructive:mango_spend` and `write:mango_spend` —
        rejecting is a final act (owner decision 2026-10-04, #6721), and
        deciding another employee's report needs the wide write scope
        (`write:own_mango_spend` covers only your own reports). A token missing
        either is refused with 403 `insufficient_permissions`.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - comment
              properties:
                comment:
                  type: string
                  maxLength: 2000
                  description: The reason for the rejection, shown to the submitter.
                    Required (422 `illegal_transition`, "Reason for rejection is required.").
                    Trimmed and cut to 2,000 characters; a non-string value counts
                    as none.
      responses:
        '200':
          description: The approval step was rejected.
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                - approval_chain_completed
                properties:
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
                  approval_chain_completed:
                    type: boolean
                    description: Whether the decision ended the report's approval
                      chain.
                  decided_in_place_of:
                    type: string
                    nullable: true
                    description: 'Set when a Mango Spend admin decided a report that
                      was waiting on someone else (its manager or a chain step): who
                      was skipped, in the sentence the review page shows before the
                      decision. Null when the report was the caller''s to decide.'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:mango_spend",
            or "Required scope: write:mango_spend (write:own_mango_spend cannot decide
            another employee''s report)."), `app_not_enabled` or `app_access_denied`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — the report is not waiting for the caller's decision
            (a report of the caller's own never is). When it was taken back to draft,
            or already decided by someone else, the message says so."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`stale_decision` — this approval step was already decided,
            or another approver decided (or the submitter took back) the report in
            the same moment. In the second case the message says who; in the first
            it says only that the step was already decided."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`illegal_transition` — no `comment` was given, it is waiting
            for a different approver, or it cannot move to rejected from its current
            status."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/approvals/{id}/request_changes":
    post:
      tags:
      - Mango Spend
      summary: Send an expense report back for changes
      description: |
        Sends a submitted expense report the caller may decide back to its
        owner to fix and submit again — the desktop and phone "Request
        changes". The report becomes `sent_back` ("Changes Requested"), ends
        its approval chain, and the owner is told. Unlike a rejection it is not
        final. A Mango Spend app admin may decide any submitted report except
        their own.

        **Token scopes:** `write:mango_spend` — deciding another employee's
        report needs the wide write scope (`write:own_mango_spend` covers only
        your own reports). Sending back is not delete-level, so
        `destructive:mango_spend` is not asked. A token missing it is refused
        with 403 `insufficient_permissions`.

        Reachable with Reimbursements switched off.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID
        schema:
          type: integer
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                comment:
                  type: string
                  maxLength: 2000
                  description: What the owner should change, shown to them on the
                    report and in their notice. Optional. Trimmed and cut to 2,000
                    characters; a non-string value counts as none.
      responses:
        '200':
          description: The report was sent back.
          content:
            application/json:
              schema:
                type: object
                required:
                - report
                - approval_chain_completed
                properties:
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
                  approval_chain_completed:
                    type: boolean
                    description: Whether the decision ended the report's approval
                      chain (it always does).
                  decided_in_place_of:
                    type: string
                    nullable: true
                    description: 'Set when a Mango Spend admin decided a report that
                      was waiting on someone else (its manager or a chain step): who
                      was skipped, in the sentence the review page shows before the
                      decision. Null when the report was the caller''s to decide.'
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"
            or "Required scope: write:mango_spend (write:own_mango_spend cannot decide
            another employee''s report)."), `app_not_enabled` or `app_access_denied`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — the report is not waiting for the caller's decision
            (a report of the caller's own never is). When it was taken back to draft,
            or already decided by someone else, the message says so."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`stale_decision` — this approval step was already decided,
            or another approver decided (or the submitter took back) the report in
            the same moment. In the second case the message says who; in the first
            it says only that the step was already decided."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`illegal_transition` — it is waiting for a different approver,
            or it cannot be sent back from its current status."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/mango_spend/expense_reports/{id}/lines/{line_id}":
    patch:
      tags:
      - Mango Spend
      summary: Change a line on your expense report
      description: |
        Updates one line on one of the caller's own reports while it is still
        open for editing — the way to price the $0 placeholder line a receipt
        upload made. Send only the fields to change; the line keeps the currency
        it was filed in, and mileage and per-diem rates cannot be set. The
        report's totals follow the change.

        **Required Scopes:** `write:mango_spend` (`write:own_mango_spend` also
        passes).

        Needs Reimbursements switched on for the business. Changing a line into
        a mileage or per-diem line is refused (403) while that module is
        switched off; a line that already is one can still be edited.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID (one of the caller's own)
        schema:
          type: integer
      - name: line_id
        in: path
        required: true
        description: Expense line ID
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - line
              properties:
                line:
                  "$ref": "#/components/schemas/MangoSpendLineInput"
      responses:
        '200':
          description: The updated line.
          content:
            application/json:
              schema:
                type: object
                required:
                - line
                properties:
                  line:
                    "$ref": "#/components/schemas/MangoSpendExpenseLine"
        '400':
          description: "`parameter_missing` — no `line` object in the body (or it
            is not an object)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:mango_spend"),
            `app_not_enabled`, `app_access_denied`, `reimbursements_disabled`, `mileage_disabled`
            or `per_diem_disabled` (the line is being changed into a type that is
            switched off).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such report of the caller's, or no such line
            on it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`report_not_editable` or `invalid_line` (the line failed validation;
            the message lists the problems)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Mango Spend
      summary: Remove a line from your expense report
      description: |
        Removes one line from one of the caller's own reports while the report is
        still open for editing.

        **Required Scopes:** `destructive:mango_spend`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:mango_spend` scope; `write:mango_spend` is not enough. A
        token without it is refused with 403 `insufficient_permissions`
        ("Required scope: destructive:mango_spend").
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Expense report ID (one of the caller's own)
        schema:
          type: integer
      - name: line_id
        in: path
        required: true
        description: Expense line ID
        schema:
          type: integer
      responses:
        '200':
          description: The line was removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - line
                - report
                properties:
                  line:
                    type: object
                    properties:
                      id:
                        type: integer
                      deleted:
                        type: boolean
                        example: true
                  report:
                    "$ref": "#/components/schemas/MangoSpendExpenseReport"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:mango_spend"),
            `app_not_enabled`, `app_access_denied` or `reimbursements_disabled`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`not_found` — no such report of the caller's, or no such line
            on it."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`report_not_editable` (the report is no longer open for editing)
            or `invalid_line` (the line could not be removed)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/password_manager/entries/{id}":
    delete:
      tags:
      - Password Manager
      summary: Delete a password entry
      description: |
        Deletes an entry for good, with an audit record of the deletion; when
        that record cannot be written the entry is left in place and the answer
        is 500.

        **Required Scopes:** `destructive:password_manager`

        Delete-level (owner decision 2026-10-04, #6659): requires the
        `destructive:password_manager` scope; `write:passwords` is not enough. A
        token without it is refused with 403 `insufficient_permissions`
        ("Required scope: destructive:password_manager").

        Deleting or archiving an entry the caller does not own also needs
        `write:passwords` on the token (`write:own_passwords` reaches only the
        caller's own entries): 403 "You do not have permission to edit this
        password".
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Password entry ID
        schema:
          type: integer
      responses:
        '200':
          description: The entry was deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - message
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Password 'Payroll portal' deleted successfully
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` in the standard error envelope,
            or `{ "success": false, "error": "…" }` — the app is not enabled or not
            open to the caller, "You do not have permission to delete this password",
            or "You do not have permission to edit this password" (an entry the caller
            does not own, on a token without `write:passwords`).'
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/PasswordManagerRefusal"
        '404':
          description: '"Password entry not found" — none in this business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PasswordManagerRefusal"
        '500':
          description: The deletion could not be recorded, so the entry was left in
            place. Try again.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PasswordManagerRefusal"
  "/password_manager/entries/{id}/archive":
    patch:
      tags:
      - Password Manager
      summary: Archive a password entry
      description: |
        Archives an entry the caller may edit. Archiving an entry that is
        already archived succeeds and changes nothing. `PATCH
        /password_manager/entries/{id}/restore` (`write:passwords`) brings it
        back.

        **Required Scopes:** `destructive:password_manager`

        Delete-level (owner decision 2026-10-04, #6659): requires the
        `destructive:password_manager` scope; `write:passwords` is not enough. A
        token without it is refused with 403 `insufficient_permissions`
        ("Required scope: destructive:password_manager").

        Deleting or archiving an entry the caller does not own also needs
        `write:passwords` on the token (`write:own_passwords` reaches only the
        caller's own entries): 403 "You do not have permission to edit this
        password".
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Password entry ID
        schema:
          type: integer
      responses:
        '200':
          description: The entry is archived.
          content:
            application/json:
              schema:
                type: object
                required:
                - success
                - message
                - entry
                properties:
                  success:
                    type: boolean
                    example: true
                  message:
                    type: string
                    example: Password archived
                  entry:
                    "$ref": "#/components/schemas/PasswordManagerEntrySummary"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` in the standard error envelope,
            or `{ "success": false, "error": "…" }` — the app is not enabled or not
            open to the caller, or "You do not have permission to edit this password".'
          content:
            application/json:
              schema:
                oneOf:
                - type: object
                  properties:
                    error:
                      type: object
                      properties:
                        code:
                          type: string
                          description: Machine readable error code
                        message:
                          type: string
                          description: Human readable error message
                        details:
                          type: object
                          description: Additional error context
                  required:
                  - error
                - "$ref": "#/components/schemas/PasswordManagerRefusal"
        '404':
          description: '"Password entry not found" — none in this business.'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PasswordManagerRefusal"
        '422':
          description: The entry could not be archived; `errors` lists why (`error_code`
            `personal_cap` when a personal-entry limit stands in the way).
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PasswordManagerRefusal"
        '500':
          description: The change could not be recorded, so nothing was archived.
            Try again.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PasswordManagerRefusal"
  "/recorder/config":
    get:
      tags:
      - Recorder
      summary: Recorder settings, preferences and capabilities
      description: |
        The desktop's tenant gate. Answers the workspace's Recorder settings a
        client can act on, the caller's own defaults, and what this caller and
        token may do. A tenant without the app answers 403 `app_not_enabled`;
        the desktop then turns off cloud actions only (capture stays local).
        The same block is `apps.recorder` in `GET /desktop/bootstrap`.

        **Token scopes:** none — Recorder reads are not scope-gated. The
        `capabilities` flags do read the token: `upload`, `share` and
        `public_links` need `write:recorder`; `send_to_chat` also needs
        `write:chat`.
      security:
      - BearerAuth: []
      responses:
        '200':
          description: The Recorder block.
          content:
            application/json:
              schema:
                type: object
                required:
                - recorder
                properties:
                  recorder:
                    "$ref": "#/components/schemas/RecorderConfig"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` (Recorder is off for this workspace) or
            `app_not_accessible` (it is not open to the caller)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/pusher/auth":
    post:
      tags:
      - Recorder
      summary: Authorize the caller's Recorder realtime channel
      description: |
        Signs a subscription to the one channel the desktop listens on,
        `private-user-recorder-{userId}-business-{businessId}`, for the
        caller's own user and business ids and nobody else's. The Pusher key
        and cluster are not served here — they come from `GET /chat/config`.

        The answer is the raw Pusher auth payload, not wrapped in an envelope,
        so it can be handed straight to the Pusher client.

        **Token scopes:** `write:recorder` (it is a POST).
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/RecorderPusherAuthRequest"
          application/x-www-form-urlencoded:
            schema:
              "$ref": "#/components/schemas/RecorderPusherAuthRequest"
      responses:
        '200':
          description: Pusher auth payload (returned direct, un-enveloped).
          content:
            application/json:
              schema:
                type: object
                properties:
                  auth:
                    type: string
        '400':
          description: "`bad_request` — `channel_name` or `socket_id` is missing,
            or `socket_id` is malformed."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible` or `forbidden` ("Unsupported channel."
            — not the caller''s own Recorder channel).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: "`pusher_unconfigured` — realtime is not configured on this
            server."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/preferences":
    patch:
      tags:
      - Recorder
      summary: Update the caller's own Recorder defaults
      description: |
        Saves the caller's own defaults — the same values the web Preferences
        page edits and `GET /recorder/config` reads back. Keys the request
        leaves out are kept. While the workspace enforces its default
        visibility (`visibility_enforced`), `default_visibility` is ignored.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Send only the fields to change. Each must be a single value
                (422 `<field>_required` for a list or an object).
              properties:
                default_visibility:
                  type: string
                  nullable: true
                  enum:
                  - private
                  - company
                  - anyone
                  - ''
                  description: The audience new captures get. Blank or null clears
                    it (the workspace default applies), except that a stored choice
                    the workspace no longer allows is kept. `anyone` needs public
                    links turned on (422 `public_links_disabled`) and the right to
                    share externally (403 `share_externally_not_permitted`).
                default_transcribe:
                  type: boolean
                  description: Whether new captures are transcribed.
      responses:
        '200':
          description: The defaults as they now stand.
          content:
            application/json:
              schema:
                type: object
                required:
                - preferences
                properties:
                  preferences:
                    "$ref": "#/components/schemas/RecorderPreferences"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible` or `share_externally_not_permitted`
            (the caller may not publish on a public link, so `anyone` cannot be their
            default).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`invalid_visibility`, `public_links_disabled`, `<field>_required`
            (a field sent as a list or an object) or `validation_error` (with an `errors`
            list)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings":
    get:
      tags:
      - Recorder
      summary: List recordings
      description: |
        The caller's library, newest first by default.

        - `scope=all` (default): every live recording the caller can watch.
        - `scope=mine`: the caller's own live recordings (`mine=1` is the older spelling).
        - `scope=shared`: what the caller can watch beyond their own — company-visible and shared with them.
        - `scope=deleted`: the recycle bin — the caller's own, or the whole
          workspace's for a Recorder admin. A row in it the caller cannot watch
          comes in the recycle-bin shape (`RecorderRecycleBinRecording`).

        `sort=title` and `scope=deleted` page by offset; every other list pages
        by recording id. `cursor_type` says which kind `next_cursor` is, so a
        client that changes the sort can reset its cursor. Pass `next_cursor`
        back as `cursor` for the next page; `total` counts the filtered set.

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - name: scope
        in: query
        required: false
        description: Which recordings. An unknown value lists `all`.
        schema:
          type: string
          enum:
          - all
          - mine
          - shared
          - deleted
          default: all
      - name: mine
        in: query
        required: false
        description: The older spelling of `scope=mine` (read only when `scope` is
          `all`).
        schema:
          type: boolean
      - name: kind
        in: query
        required: false
        description: Only this kind. Any other value is ignored.
        schema:
          type: string
          enum:
          - video
          - screenshot
      - name: q
        in: query
        required: false
        description: Title search. Trimmed and cut to 100 characters.
        schema:
          type: string
      - name: sort
        in: query
        required: false
        description: Order. An unknown value sorts `newest`.
        schema:
          type: string
          enum:
          - newest
          - title
          default: newest
      - "$ref": "#/components/parameters/RecorderCursor"
      - name: limit
        in: query
        required: false
        description: Page size, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of recordings.
          content:
            application/json:
              schema:
                type: object
                required:
                - recordings
                - scope
                - sort
                - total
                - cursor_type
                - next_cursor
                properties:
                  recordings:
                    type: array
                    items:
                      anyOf:
                      - "$ref": "#/components/schemas/RecorderRecording"
                      - "$ref": "#/components/schemas/RecorderRecycleBinRecording"
                  scope:
                    type: string
                    enum:
                    - all
                    - mine
                    - shared
                    - deleted
                  sort:
                    type: string
                    enum:
                    - newest
                    - title
                  total:
                    type: integer
                    description: Recordings matching the filters, across every page.
                  cursor_type:
                    type: string
                    enum:
                    - id
                    - offset
                  next_cursor:
                    type: integer
                    nullable: true
                    description: The `cursor` for the next page; null on the last
                      page.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` or `app_not_accessible`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`q_required`, `kind_required`, `scope_required` or `sort_required`
            — that parameter was sent as a list or an object."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    post:
      tags:
      - Recorder
      summary: Create a recording
      description: |
        Creates the row a capture's bytes are then uploaded to (`media/init` +
        `media/complete`, or the multipart `media`). The new recording is
        `uploading` until its bytes arrive.

        The same `client_capture_id` from the same person returns the existing
        row (200, `existing: true`) instead of a twin — revived from the recycle
        bin if needed, and put back to `uploading` if it never got its bytes.

        Free text is cut, never refused: `title` is trimmed and cut to 150
        characters. A value outside its range (`captured_at`, `duration_ms`,
        `width`, `height`) is stored as empty rather than refused.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: Every field is optional. Each except `metadata` must be
                a single value (422 `<field>_required` for a list or an object).
              properties:
                kind:
                  type: string
                  enum:
                  - video
                  - screenshot
                  default: video
                title:
                  type: string
                  maxLength: 150
                  description: Trimmed and cut to 150 characters.
                client_capture_id:
                  type: string
                  maxLength: 255
                  description: The client's own id for this capture; makes a retried
                    create return the same row (422 `client_capture_id_too_long` over
                    255 characters).
                captured_at:
                  type: string
                  format: date-time
                  description: When it was captured. Unparseable, or outside the years
                    1970–9999, is stored as empty.
                duration_ms:
                  type: integer
                  description: Length in milliseconds, stored as whole seconds. Only
                    1 ms to 24 hours is kept.
                width:
                  type: integer
                  description: Pixels, 1–16,384; anything else is stored as empty.
                height:
                  type: integer
                  description: Pixels, 1–16,384; anything else is stored as empty.
                visibility:
                  type: string
                  enum:
                  - private
                  - company
                  - anyone
                  description: Used when the workspace allows it for this person and
                    does not enforce its own default; otherwise the caller's default
                    (or the workspace's) applies. Never refused.
                source:
                  type: string
                  enum:
                  - desktop
                  - web
                  - mobile
                  default: desktop
                  description: Any other value is stored as `desktop`.
                metadata:
                  type: object
                  additionalProperties: true
                  description: Free-form capture facts. At most 20 keys are kept,
                    each cut to 64 characters; values must be a string (cut to 200
                    characters), a boolean or a number under 10^15 in size — anything
                    else is dropped.
      responses:
        '201':
          description: The recording was created.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderCreatedRecording"
        '200':
          description: This person already has a recording with that `client_capture_id`;
            it is returned instead.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderCreatedRecording"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled` or `app_not_accessible`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '422':
          description: "`invalid_kind`, `client_capture_id_too_long`, `<field>_required`
            (a field sent as a list or an object) or `validation_error` (with an `errors`
            list)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}":
    get:
      tags:
      - Recorder
      summary: Get a recording
      description: |
        One recording in its detail form (`detail: true`), with a signed media
        URL good for one hour. A recording in the recycle bin answers only its
        owner, in the list form (no media URL, no share list); anyone else gets
        404 `recording_not_found`.

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      responses:
        '200':
          description: The recording.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                properties:
                  recording:
                    "$ref": "#/components/schemas/RecorderRecording"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` or `app_not_accessible`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
    patch:
      tags:
      - Recorder
      summary: Update a recording
      description: |
        Changes a recording's title, description, call to action, visibility or
        download setting. The caller must be able to edit the recording; only its
        owner may change `allow_download`. A recording in the recycle bin cannot
        be edited (403 `recording_deleted`).

        Leaving `anyone` visibility closes the recording's automatic public link:
        the URL never works again (a later `anyone` makes a new one). The answer
        then carries `public_link_closed: true` and a sentence saying so.

        **Token scopes:** `write:recorder`, and also `destructive:recorder` when
        the request moves `visibility` away from `anyone` while the automatic
        public link is live — closing that link is a final act (owner decision
        2026-10-07). Any other update needs only the write scope.

        `PUT` is accepted as an alias of `PATCH`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Send only the fields to change. Each must be a single value
                (422 `<field>_required` for a list or an object).
              properties:
                title:
                  type: string
                  maxLength: 150
                  description: Trimmed and cut to 150 characters.
                description:
                  type: string
                  maxLength: 5000
                  description: Cut to 5,000 characters.
                cta_label:
                  type: string
                  description: The call-to-action button's label; blank removes it.
                cta_url:
                  type: string
                  description: The call-to-action link; blank removes it.
                visibility:
                  type: string
                  enum:
                  - private
                  - company
                  - anyone
                  description: "`anyone` needs public links turned on for the workspace
                    (422 `public_links_disabled`) and the right to share externally
                    (403 `share_externally_not_permitted`)."
                allow_download:
                  type: boolean
                  description: Owner only.
      responses:
        '200':
          description: The recording was updated.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                properties:
                  recording:
                    "$ref": "#/components/schemas/RecorderRecording"
                  public_link_closed:
                    type: boolean
                    description: Present when this change closed the automatic public
                      link.
                  public_link_closed_message:
                    type: string
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder",
            or "Required scope: destructive:recorder" when the change closes the public
            link), `app_not_enabled`, `app_not_accessible`, `forbidden` (the caller
            cannot edit the recording, or is not its owner for `allow_download`),
            `recording_deleted` or `share_externally_not_permitted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '422':
          description: "`invalid_visibility`, `public_links_disabled`, `<field>_required`
            (a field sent as a list or an object) or `validation_error` (with an `errors`
            list)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Recorder
      summary: Delete a recording
      description: |
        Moves the caller's own recording to the recycle bin; it can be restored
        until it is purged (`days_until_purge`). Deleting a recording already in
        the recycle bin answers the same success.

        **Required Scopes:** `destructive:recorder`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recorder` scope; `write:recorder` is not enough. A token
        without it is refused with 403 `insufficient_permissions` ("Required
        scope: destructive:recorder").
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      responses:
        '200':
          description: The recording is in the recycle bin.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                - deleted
                properties:
                  recording:
                    "$ref": "#/components/schemas/RecorderRecording"
                  deleted:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:recorder"),
            `app_not_enabled`, `app_not_accessible` or `forbidden` ("Only the owner
            can do that.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`recording_not_found`, or `recording_gone` (the recording
            was removed while this request ran)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
  "/recorder/recordings/{id}/status":
    get:
      tags:
      - Recorder
      summary: Poll a recording's processing status
      description: |
        The small answer a client polls while a capture is processed
        (`uploading` → `processing` → `ready` or `failed`). The media and poster
        URLs are signed for one hour and are null for a recording in the
        recycle bin, which answers only its owner (anyone else gets 404).

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      responses:
        '200':
          description: The recording's status.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording_id
                - status
                properties:
                  recording_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - uploading
                    - processing
                    - ready
                    - failed
                  processing_stage:
                    type: string
                    nullable: true
                  error_message:
                    type: string
                    nullable: true
                    description: Set only when `status` is `failed`.
                  url:
                    type: string
                    nullable: true
                    description: Signed media URL, once the recording is ready.
                  thumbnail_url:
                    type: string
                    nullable: true
                  web_url:
                    type: string
                    nullable: true
                  duration_seconds:
                    type: number
                    nullable: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` or `app_not_accessible`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
  "/recorder/recordings/{id}/restore":
    post:
      tags:
      - Recorder
      summary: Restore a recording from the recycle bin
      description: |
        Takes a recording out of the recycle bin. Its owner may restore it, and
        so may a Recorder admin (for any recording in the bin). Restoring one
        that is not in the bin answers 200 with `restored: false`. A capture
        whose upload never finished comes back as `failed`, ready to be
        uploaded again.

        An admin restoring a recording they cannot watch gets it back in the
        recycle-bin shape only.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      responses:
        '200':
          description: The recording is out of the recycle bin.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                - restored
                properties:
                  recording:
                    anyOf:
                    - "$ref": "#/components/schemas/RecorderRecording"
                    - "$ref": "#/components/schemas/RecorderRecycleBinRecording"
                  restored:
                    type: boolean
                    description: False when the recording was not in the recycle bin.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled` or `app_not_accessible`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`recording_not_found` (no such recording the caller may restore),
            or `recording_gone` (it was purged while this request ran)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/transcript":
    get:
      tags:
      - Recorder
      summary: Get a recording's transcript
      description: |
        The transcript segments for the native player's transcript panel, at
        most 10,000 of them. A recording with no transcript, or one in the
        recycle bin (which answers only its owner), answers an empty list.

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      responses:
        '200':
          description: The transcript.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording_id
                - language
                - has_transcript
                - segments
                properties:
                  recording_id:
                    type: integer
                  language:
                    type: string
                    description: The recording's own language code (`en` when unknown).
                  has_transcript:
                    type: boolean
                    description: The recording has a transcript — the same answer
                      as the detail's `has_transcript`. Can be true with no `segments`
                      when the text exists but no timed segment parsed; show `text`.
                  segments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecorderTranscriptSegment"
                  segments_truncated:
                    type: boolean
                    description: The transcript has more segments than the 10,000
                      `segments` carries.
                  segment_count:
                    type: integer
                    description: How many timed segments the whole transcript has.
                  text:
                    type: string
                    nullable: true
                    description: The whole transcript as plain text.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` or `app_not_accessible`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
  "/recorder/recordings/{id}/captions":
    get:
      tags:
      - Recorder
      summary: Get a recording's captions
      description: |
        The WebVTT captions the web player's track uses, as JSON, so a
        bearer-authenticated client can play them. With `lang`, a ready caption
        track in that language, or the transcript when `lang` is the
        recording's own language; without it, the transcript. `languages`
        lists what is on offer.

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - name: lang
        in: query
        required: false
        description: A language code (`en`, `pt-BR`) or an English language name.
        schema:
          type: string
      responses:
        '200':
          description: The captions.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording_id
                - language
                - languages
                - vtt
                properties:
                  recording_id:
                    type: integer
                  language:
                    type: string
                    description: The language of `vtt`.
                  languages:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecorderCaptionLanguage"
                  vtt:
                    type: string
                    description: A WebVTT document.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` or `app_not_accessible`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`recording_not_found`, or `captions_not_found` (no captions
            in that language — also the answer for a recording in the recycle bin)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`lang_required` — `lang` was sent as a list or an object."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/media/init":
    post:
      tags:
      - Recorder
      summary: Start a direct media upload
      description: |
        The first step of the direct upload: checks the declared file and
        answers a storage URL to `PUT` the bytes to, with the headers to send.
        Then call `media/complete` with `upload.signed_blob_id`. The URL
        expires (`expires_in`, `expires_at`); a repeated init mints a fresh one.
        Only the recording's owner may upload its media, and not while it is in
        the recycle bin.

        The `Idempotency-Key` header is not honoured here, on purpose: a
        replayed answer would carry an expired URL.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - filename
              - content_type
              - byte_size
              - checksum
              description: Each must be a single value (422 `<field>_required` for
                a list or an object).
              properties:
                filename:
                  type: string
                  description: Cut to 180 characters, keeping the extension.
                content_type:
                  "$ref": "#/components/schemas/RecorderMediaType"
                byte_size:
                  type: integer
                  minimum: 1
                  description: At most the workspace's upload cap (`settings.max_upload_size_mb`
                    in `GET /recorder/config`).
                checksum:
                  type: string
                  maxLength: 64
                  description: Base64 MD5 of the bytes (24 characters).
      responses:
        '200':
          description: Where to send the bytes.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording_id
                - upload
                - limits
                properties:
                  recording_id:
                    type: integer
                  upload:
                    type: object
                    properties:
                      signed_blob_id:
                        type: string
                        description: Good only for this recording's `media/complete`.
                      url:
                        type: string
                        description: The storage URL to `PUT` the bytes to.
                      headers:
                        type: object
                        additionalProperties:
                          type: string
                        description: Headers to send with the `PUT`.
                      byte_size:
                        type: integer
                      content_type:
                        type: string
                      declared_content_type:
                        type: string
                      expires_in:
                        type: integer
                        nullable: true
                        description: Seconds until `url` expires.
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                  limits:
                    "$ref": "#/components/schemas/RecorderUploadLimits"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `forbidden` ("Only the owner
            can do that.") or `recording_deleted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '413':
          "$ref": "#/components/responses/RecorderMediaTooLarge"
        '415':
          "$ref": "#/components/responses/RecorderMediaTypeUnsupported"
        '422':
          description: "`upload_metadata_required` (a field is missing, or `byte_size`
            is not above zero), `checksum_too_long` or `<field>_required`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '502':
          description: "`upload_url_unavailable` — storage could not issue an upload
            URL; retry."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/media/complete":
    post:
      tags:
      - Recorder
      summary: Finish a direct media upload
      description: |
        Attaches the uploaded bytes to the recording and starts processing. The
        `signed_blob_id` comes from `media/init`, or — for a capture too large
        for one request — from finishing an upload session (`POST
        /uploads/sessions` with purpose `recorder`). The bytes are virus-scanned
        and their real type checked here. Only the recording's owner may call
        it, and not while it is in the recycle bin.

        A retry with the same bytes keeps everything already derived from them.
        New bytes replace the media and clear what came from the old ones: the
        transcript, summary, chapters, caption tracks and annotations bundle.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - signed_blob_id
              properties:
                signed_blob_id:
                  type: string
                  description: A single value (422 `signed_blob_id_required` for a
                    list or an object).
      responses:
        '202':
          "$ref": "#/components/responses/RecorderProcessingStarted"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `forbidden` ("Only the owner
            can do that.") or `recording_deleted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`recording_not_found`, or `blob_not_found` (no such upload
            this caller may attach here)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '413':
          "$ref": "#/components/responses/RecorderMediaTooLarge"
        '415':
          "$ref": "#/components/responses/RecorderMediaTypeUnsupported"
        '422':
          description: "`blob_id_required`, `signed_blob_id_required`, `upload_metadata_required`
            (the upload is empty), `upload_incomplete` (the bytes never finished arriving,
            or do not match the declared checksum — start a new upload), `upload_blocked`
            (the virus scan refused the file; nothing was kept) or `validation_error`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: 'Worth retrying: `scanner_unavailable` (virus scanning is down
            and this workspace refuses uploads meanwhile), `storage_unavailable` (file
            storage could not be read) or `processing_unavailable` (the bytes are
            attached but processing could not be started; the recording is marked
            `failed` — send `media/complete` again).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/media":
    post:
      tags:
      - Recorder
      summary: Upload media in one request (multipart)
      description: |
        The small-file fallback for `media/init` + `media/complete`: the
        capture in a multipart `file` field. Same checks and outcome as
        `media/complete` — owner only, virus-scanned, the real type checked,
        processing started — and the same rule for a retry with the same bytes.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The capture (WebM, MP4 or QuickTime video, or a PNG
                    or JPEG screenshot).
      responses:
        '202':
          "$ref": "#/components/responses/RecorderProcessingStarted"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `forbidden` ("Only the owner
            can do that.") or `recording_deleted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '413':
          "$ref": "#/components/responses/RecorderMediaTooLarge"
        '415':
          "$ref": "#/components/responses/RecorderMediaTypeUnsupported"
        '422':
          description: "`file_required`, `upload_metadata_required` (the file is empty),
            `upload_blocked` (the virus scan refused it; nothing was kept), `upload_incomplete`
            or `validation_error`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '502':
          description: "`upload_storage_unavailable` — storage refused the bytes;
            nothing changed on the recording. Send the file again."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '503':
          description: "`scanner_unavailable` (virus scanning is down; nothing was
            stored) or `processing_unavailable` (the bytes are attached but processing
            could not be started; the recording is marked `failed`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/thumbnail":
    post:
      tags:
      - Recorder
      summary: Set a recording's poster image
      description: |
        Replaces the recording's poster with an image the client supplies, in a
        multipart `file` field: PNG, JPEG or WebP, at most 2 MB. Both the
        declared type and the bytes themselves must be one of those. The caller
        must be able to edit the recording, and it must not be in the recycle
        bin.

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: A PNG, JPEG or WebP image, at most 2 MB.
      responses:
        '200':
          description: The poster was replaced.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                properties:
                  recording:
                    "$ref": "#/components/schemas/RecorderRecording"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `forbidden` (the caller cannot
            edit the recording) or `recording_deleted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`recording_not_found`, or `recording_gone` (the recording
            was removed while this request ran)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '413':
          description: "`file_too_large` — over 2 MB."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '415':
          description: "`unsupported_media_type` — not a PNG, JPEG or WebP (declared
            or by its bytes); `error.details.allowed_types` lists them."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`file_required`, `upload_metadata_required` (the file is empty)
            or `validation_error`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/send_to_chat":
    post:
      tags:
      - Recorder
      summary: Send a recording to a chat room
      description: |
        Posts a card with the recording's title, an optional note and its link
        into a chat room the caller is a member of and may post in. The
        message is queued (202). The same card into the same room within ten
        minutes is treated as a retry and answers 200 `duplicate` with the
        message already posted.

        `link: company` (the default) sends the link for signed-in colleagues;
        `link: anyone` sends a public link (made if needed), which needs public
        links turned on for the workspace, the caller able to edit the
        recording, and the caller allowed to share externally.

        Gates after Recorder's: the token's `write:chat`, then the Chat app
        (403 `chat_not_enabled` / `chat_not_accessible`, with Chat's message —
        Chat's own codes, so they are never read as Recorder being off). A
        colleague's recording in the recycle bin answers 404, as the reads do;
        only its owner hears `recording_deleted`.

        **Token scopes:** `write:recorder` and `write:chat`. A token holding
        only `write:own_chat` passes, but may not post as a business admin
        into a room only admins may post in.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - room_id
              description: Each must be a single value (422 `<field>_required` for
                a list or an object).
              properties:
                room_id:
                  type: integer
                  description: The chat room (422 `room_id_required` when missing).
                note:
                  type: string
                  maxLength: 1000
                  description: Trimmed and cut to 1,000 characters.
                link:
                  type: string
                  enum:
                  - company
                  - anyone
                  default: company
                  description: Any other value sends the company link.
                client_uuid:
                  type: string
                  maxLength: 64
                  description: The client's own id for the chat message.
      responses:
        '202':
          description: The card is queued for the room.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderSentToChat"
        '200':
          description: The same card was already sent to this room in the last ten
            minutes; nothing new was posted.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderSentToChat"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"
            or "Required scope: write:chat"), `app_not_enabled` / `app_not_accessible`
            (Recorder), `chat_not_enabled` / `chat_not_accessible` (Chat), `recording_deleted`
            (the owner''s own recording is in the recycle bin), `forbidden` (not a
            member of the room; an `anyone` link from someone who cannot edit the
            recording or may not share externally), `im_not_allowed` or `information_barrier`
            (the caller may not post in this room).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: "`recording_not_found`, or `room_not_found` (no such live chat
            room in this workspace)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          description: "`recently_sent` (this card was sent to the room moments ago
            and then deleted; wait a few minutes), `idempotency_conflict` or `idempotency_in_progress`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`room_id_required`, `public_links_disabled` (send the company
            link instead), `client_uuid_too_long`, `message_too_long` or `<field>_required`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/watch":
    post:
      tags:
      - Recorder
      summary: Report what a signed-in viewer watched
      description: |
        The player's engagement beacon. The first beacon counts a play (one per
        viewer per 30 minutes); every beacon merges its watched ranges into
        that play, which feeds the owner's completion rate, drop-off and
        heatmap. The owner watching their own recording is not counted (still
        204).

        **Token scopes:** `write:recorder`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ranges:
                  type: array
                  description: Watched spans as `[from_seconds, to_seconds]` pairs
                    (also accepted as that array in a JSON string). At most 200 are
                    read; a pair that is not two numbers with `from` ≥ 0 and `to`
                    > `from` is dropped, and spans are clipped to the recording's
                    length.
                  maxItems: 200
                  items:
                    type: array
                    minItems: 2
                    maxItems: 2
                    items:
                      type: number
      responses:
        '204':
          description: Recorded (or the caller is the owner, which is not counted).
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible` or `recording_not_ready` (the
            recording is not ready to play, or the caller''s own recording is in the
            recycle bin — a colleague''s answers 404).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '503':
          description: "`view_not_recorded` — the view could not be saved; send the
            beacon again."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{recording_id}/share_links":
    get:
      tags:
      - Recorder
      summary: List a recording's public links
      description: |
        The recording's public links that have not been revoked, newest first
        (an expired or used-up link is listed with its `status`). The caller
        must be able to edit the recording — a link's URL is the credential
        for outsiders — and it must not be in the recycle bin.

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - "$ref": "#/components/parameters/RecorderCursor"
      - name: limit
        in: query
        required: false
        description: Page size, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      responses:
        '200':
          description: One page of public links.
          content:
            application/json:
              schema:
                type: object
                required:
                - share_links
                - total
                - next_cursor
                - public_links_enabled
                properties:
                  share_links:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecorderShareLink"
                  total:
                    type: integer
                    description: Links not revoked, across every page.
                  next_cursor:
                    type: integer
                    nullable: true
                    description: The `cursor` (an offset) for the next page; null
                      on the last page.
                  public_links_enabled:
                    type: boolean
                    description: Whether the workspace allows public links at all.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled`, `app_not_accessible`, `recording_deleted`
            (in the recycle bin) or `forbidden` (the caller cannot edit the recording)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
    post:
      tags:
      - Recorder
      summary: Create a public link
      description: |
        Makes a public "anyone with the link" URL for the recording. Needs
        public links turned on for the workspace, the caller able to edit the
        recording and allowed to share externally, and the recording out of the
        recycle bin.

        Expiry follows the workspace's `public_link_max_days`: above 0, a blank
        `expires_at` gets that many days and a later date is pulled back to the
        cap; at 0 (no limit), a blank one never expires. A date given must be in
        the future (and, without a cap, within 100 years); a bare date means the
        end of that day.

        **Token scopes:** `write:recorder` (revoking a link asks
        `destructive:recorder`).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              description: Every field is optional. Each must be a single value (422
                `<field>_required` for a list or an object).
              properties:
                label:
                  type: string
                  maxLength: 80
                  description: Trimmed and cut to 80 characters.
                expires_at:
                  type: string
                  description: A date or a date-time (422 `invalid_expiry` when unparseable
                    or not in the future).
                password:
                  type: string
                  description: Asked of anyone opening the link. At most 72 bytes
                    (422 `validation_error` beyond).
                max_views:
                  type: integer
                  minimum: 1
                  description: Opens allowed; above 100,000 is cut to 100,000. Leave
                    out for unlimited (422 `invalid_max_views` for zero, a negative
                    or a non-number).
                require_email:
                  type: boolean
                  default: false
                  description: Ask a viewer for their email before playing.
                allow_download:
                  type: boolean
                  default: false
                  description: Offer a download through the link; it works only while
                    the recording itself allows downloads.
      responses:
        '201':
          description: The link was created.
          content:
            application/json:
              schema:
                type: object
                required:
                - share_link
                properties:
                  share_link:
                    "$ref": "#/components/schemas/RecorderShareLink"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted`, `forbidden`
            (the caller cannot edit the recording) or `share_externally_not_permitted`
            (the workspace limits who may publish on a public link).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '422':
          description: "`public_links_disabled` (turned off for this workspace), `invalid_expiry`,
            `invalid_max_views`, `<field>_required` or `validation_error` (with an
            `errors` list)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{recording_id}/chat_rooms/{room_id}":
    delete:
      tags:
      - Recorder
      summary: Stop a chat room watching a recording
      description: |
        The web Access card's Stop: a chat room the recording was sent to can no
        longer watch it. The card stays in the room. The caller must be able to
        edit the recording and to manage who it is shared with
        (`can_manage_sharing`). The answer lists the rooms still open, as
        `shared_with_rooms` on the recording does.

        **Required Scopes:** `destructive:recorder`

        Delete-level (owner decision 2026-10-04, #6721): `write:recorder` is not enough.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - name: room_id
        in: path
        required: true
        description: Chat room ID (`shared_with_rooms[].room_id`)
        schema:
          type: integer
      responses:
        '200':
          description: The room can no longer watch it.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recording_id:
                    type: integer
                  shared_with_rooms:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecorderSharedRoom"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted`, `forbidden`
            (the caller cannot edit the recording) or `resharing_not_permitted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`recording_not_found`, or `chat_room_not_found` ("That chat
            room can''t watch this recording.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{recording_id}/comments":
    get:
      tags:
      - Recorder
      summary: List a recording's comments
      description: |
        The comment thread, oldest first: top-level comments, each with up to
        50 of its replies (`reply_count` and `replies_truncated` say when there
        are more). Anyone who can watch the recording may read it; a recording
        in the recycle bin answers only its owner.

        The cursor is an offset over top-level comments, so a page always
        holds whole threads. `total` counts top-level comments;
        `comments_count` counts every live comment including replies (the
        number on the recording's card).

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - "$ref": "#/components/parameters/RecorderCursor"
      - name: limit
        in: query
        required: false
        description: Top-level comments per page, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 50
      responses:
        '200':
          description: One page of the thread.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording_id
                - can_comment
                - comments
                - total
                - comments_count
                - next_cursor
                properties:
                  recording_id:
                    type: integer
                  can_comment:
                    type: boolean
                    description: The recording is out of the recycle bin and the token
                      carries `write:recorder`.
                  comments:
                    type: array
                    items:
                      "$ref": "#/components/schemas/RecorderComment"
                  total:
                    type: integer
                    description: Top-level comments, across every page.
                  comments_count:
                    type: integer
                    description: Every live comment, replies included.
                  next_cursor:
                    type: integer
                    nullable: true
                    description: The `cursor` (an offset) for the next page; null
                      on the last page.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled` or `app_not_accessible`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
    post:
      tags:
      - Recorder
      summary: Comment on a recording
      description: |
        Posts a comment, optionally pinned to a moment in the recording or as a
        reply. Anyone who can watch the recording may comment, while it is out
        of the recycle bin. The comment is screened by the workspace's content
        moderation before it is saved. The recording's owner and anyone
        @mentioned are notified.

        Threads are one level deep: a reply to a reply is filed under the
        top-level comment.

        **Token scopes:** `write:recorder` (deleting a comment asks
        `destructive:recorder`).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              description: Each must be a single value (422 `<field>_required` for
                a list or an object).
              properties:
                body:
                  type: string
                  maxLength: 4000
                  description: Trimmed; at most 4,000 characters (422 `body_too_long`,
                    never cut). An @mention counts at its stored length, which is
                    longer than the name shown.
                anchor_seconds:
                  type: integer
                  minimum: 0
                  description: A moment in the recording, in seconds. Ignored when
                    the recording has no length or the moment is past its end.
                parent_comment_id:
                  type: integer
                  description: The comment this replies to.
      responses:
        '201':
          description: The comment was posted.
          content:
            application/json:
              schema:
                type: object
                required:
                - comment
                properties:
                  comment:
                    "$ref": "#/components/schemas/RecorderComment"
                  mentions_capped:
                    type: integer
                    description: Present when the notification cap left this many
                      mentioned people untold.
                  mentions_unreachable:
                    type: integer
                    description: Present when this many mentioned people were not
                      told because they cannot open the recording.
                  mentions_unreachable_message:
                    type: string
                    description: The sentence to show beside `mentions_unreachable`.
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible` or `recording_deleted` (in the
            recycle bin).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`recording_not_found`, or `parent_not_found` ("That comment
            is gone already.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '422':
          description: "`body_required`, `body_too_long`, `content_moderation_blocked`
            (the message says why), `comment_invalid`, `<field>_required` or `validation_error`
            (with an `errors` list)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{recording_id}/shares":
    get:
      tags:
      - Recorder
      summary: List who a recording is shared with
      description: |
        The people a recording is shared with, oldest grant first. The caller
        must be able to edit the recording, and it must not be in the recycle
        bin. The owner is not listed (they always have full access).

        **Token scopes:** none — Recorder reads are not scope-gated.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - "$ref": "#/components/parameters/RecorderCursor"
      - name: limit
        in: query
        required: false
        description: Page size, 1–100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 100
      responses:
        '200':
          description: One page of the share list.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderShareRoster"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: "`app_not_enabled`, `app_not_accessible`, `recording_deleted`
            (in the recycle bin) or `forbidden` (the caller cannot edit the recording)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
    post:
      tags:
      - Recorder
      summary: Share a recording with a person
      description: |
        Gives one person `viewer` or `editor` access. The caller must be able to
        edit the recording and to manage who it is shared with (its owner, or
        an editor while the workspace lets editors re-share), and it must not be
        in the recycle bin. The person must be an active member of the
        workspace who can open Recorder.

        For a person not yet on the list, an unrecognised `permission` grants
        `viewer` (201). For a person already on it, this changes their access:
        `permission` is then required and must be `viewer` or `editor` (200,
        `existing: true`). The answer is the share list as it now stands (first
        page).

        **Token scopes:** `write:recorder` (removing a person asks
        `destructive:recorder`).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              description: Each must be a single value (422 `<field>_required` for
                a list or an object).
              properties:
                user_id:
                  type: integer
                  description: The person to share with.
                permission:
                  type: string
                  enum:
                  - viewer
                  - editor
                  description: The owner's access is the creator's and cannot be granted.
      responses:
        '201':
          description: The person was added.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderShareRosterAfterShare"
        '200':
          description: The person was already on the list; their access was set to
            `permission`.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderShareRosterAfterShare"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted`, `forbidden`
            (the caller cannot edit the recording) or `resharing_not_permitted` ("Only
            the person who made this recording can choose who it is shared with.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '422':
          description: "`user_not_grantable` (not an active member who can open Recorder),
            `owner_already` (that person made the recording), `permission_required`
            or `invalid_permission` (a person already on the list, with `permission`
            missing or not `viewer`/`editor`), `user_id_required` (sent as a list
            or an object), or `validation_error` (the share could not be saved; `errors`
            says why)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{id}/annotations":
    post:
      tags:
      - Recorder
      summary: Save a screenshot's annotations
      description: |
        Stores the desktop editor's annotations bundle on a screenshot, so it
        can be edited again later from any desktop. The bundle is the
        `<file>.annotations.json` document the desktop writes, sent as-is in a
        multipart `file` field, at most 60 MB. The screenshot's media must be
        uploaded first. The caller must be able to edit the recording, and it
        must not be in the recycle bin. Editors read the bundle back from
        `annotations_url` on the recording's detail.

        **Token scopes:** `write:recorder` (removing the annotations asks
        `destructive:recorder`).
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      - "$ref": "#/components/parameters/RecorderIdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
              - file
              properties:
                file:
                  type: string
                  format: binary
                  description: 'A JSON document with `format: "mangoapps-desktop/annotations"`,
                    an integer `version`, an `ops` array and an `original` whose `dataUrl`
                    is an image data URL. At most 60 MB.'
      responses:
        '200':
          description: The annotations were saved.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                properties:
                  recording:
                    "$ref": "#/components/schemas/RecorderRecording"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted` (in the recycle
            bin) or `forbidden` (the caller cannot edit the recording).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
        '409':
          "$ref": "#/components/responses/RecorderIdempotencyConflict"
        '413':
          description: "`file_too_large` — over 60 MB."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`file_required`, `not_a_screenshot` (only a screenshot carries
            annotations), `media_required` (upload the screenshot first), `upload_metadata_required`
            (the file is empty), `invalid_annotations_bundle` (not an annotations
            bundle) or `validation_error`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Recorder
      summary: Remove a recording's annotations
      description: |
        Removes the drawn annotations from a recording the caller can edit. The
        stored file is purged in the background.

        **Required Scopes:** `destructive:recorder`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recorder` scope; `write:recorder` (which saving annotations
        asks) is not enough.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderRecordingId"
      responses:
        '200':
          description: The annotations were removed.
          content:
            application/json:
              schema:
                type: object
                required:
                - recording
                properties:
                  recording:
                    "$ref": "#/components/schemas/RecorderRecording"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted` (in the recycle
            bin) or `forbidden` (the caller cannot edit the recording).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          "$ref": "#/components/responses/RecorderRecordingNotFound"
  "/recorder/recordings/{recording_id}/comments/{comment_id}":
    delete:
      tags:
      - Recorder
      summary: Delete a comment
      description: |
        Deletes a comment on a recording together with its replies (and their
        replies). A comment's author may delete it, and so may the recording's
        owner.

        **Required Scopes:** `destructive:recorder`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recorder` scope; `write:recorder` is not enough.
      security:
      - BearerAuth: []
      parameters:
      - name: recording_id
        in: path
        required: true
        description: Recording ID
        schema:
          type: integer
      - name: comment_id
        in: path
        required: true
        description: Comment ID
        schema:
          type: integer
      responses:
        '200':
          description: The comment and its replies were deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                - deleted
                - comment_id
                - deleted_reply_ids
                properties:
                  deleted:
                    type: boolean
                    example: true
                  comment_id:
                    type: integer
                  deleted_reply_ids:
                    type: array
                    items:
                      type: integer
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted` (in the recycle
            bin) or `forbidden` ("You can only delete your own comments.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`recording_not_found`, or `comment_not_found` ("That comment
            is gone already.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`comment_delete_failed` — the comment could not be deleted;
            try again."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{recording_id}/shares/{share_id}":
    patch:
      tags:
      - Recorder
      summary: Change a person's access to a recording
      description: |
        Sets one share's `permission`. The caller must be able to edit the
        recording and to manage who it is shared with, and it must not be in
        the recycle bin. The answer is the share list as it now stands (first
        page).

        **Token scopes:** `write:recorder`.

        `PUT` is accepted as an alias of `PATCH`.
      security:
      - BearerAuth: []
      parameters:
      - "$ref": "#/components/parameters/RecorderParentRecordingId"
      - name: share_id
        in: path
        required: true
        description: Share ID (`shares[].id`)
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - permission
              properties:
                permission:
                  type: string
                  enum:
                  - viewer
                  - editor
                  description: Required; a single value.
      responses:
        '200':
          description: The access was changed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderShareRoster"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: write:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted`, `forbidden`
            (the caller cannot edit the recording) or `resharing_not_permitted`.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`recording_not_found`, or `share_not_found` ("That share no
            longer exists.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`permission_required` (missing, or sent as a list or an object),
            `invalid_permission` (not `viewer` or `editor`; `error.details.permission`
            echoes what was sent) or `validation_error` (the change could not be saved;
            `errors` says why)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
    delete:
      tags:
      - Recorder
      summary: Stop sharing a recording with a person
      description: |
        Removes one person from a recording's share list. The caller must be able
        to edit the recording and to manage who it is shared with. The answer is
        the share list as it now stands (first page).

        **Required Scopes:** `destructive:recorder`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recorder` scope; `write:recorder` (which adding or changing
        a share asks) is not enough.
      security:
      - BearerAuth: []
      parameters:
      - name: recording_id
        in: path
        required: true
        description: Recording ID
        schema:
          type: integer
      - name: share_id
        in: path
        required: true
        description: Share ID (`shares[].id`)
        schema:
          type: integer
      responses:
        '200':
          description: The person was removed from the share list.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/RecorderShareRoster"
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted`, `forbidden`
            (the caller cannot edit the recording) or `resharing_not_permitted` ("Only
            the person who made this recording can choose who it is shared with.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`recording_not_found`, or `share_not_found` ("That share no
            longer exists.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recorder/recordings/{recording_id}/share_links/{id}":
    delete:
      tags:
      - Recorder
      summary: Revoke a public link
      description: |
        Revokes one of a recording's live public links; its URL stops working.
        The caller must be able to edit the recording.

        **Required Scopes:** `destructive:recorder`

        Delete-level (owner decision 2026-10-04, #6721): requires the
        `destructive:recorder` scope; `write:recorder` (which creating a link
        asks) is not enough.
      security:
      - BearerAuth: []
      parameters:
      - name: recording_id
        in: path
        required: true
        description: Recording ID
        schema:
          type: integer
      - name: id
        in: path
        required: true
        description: Public link ID
        schema:
          type: integer
      responses:
        '200':
          description: The link was revoked.
          content:
            application/json:
              schema:
                type: object
                required:
                - share_link
                - revoked
                properties:
                  share_link:
                    "$ref": "#/components/schemas/RecorderShareLink"
                  revoked:
                    type: boolean
                    example: true
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`insufficient_permissions` ("Required scope: destructive:recorder"),
            `app_not_enabled`, `app_not_accessible`, `recording_deleted` or `forbidden`
            (the caller cannot edit the recording).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`recording_not_found`, or `share_link_not_found` ("That public
            link no longer exists.").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
  "/recruiting/candidates/{id}":
    patch:
      tags:
      - Recruiting
      summary: Update a candidate
      description: |
        Moves a candidate to another pipeline status and/or replaces their
        notes. Rejecting a candidate records the reason on their timeline and,
        when the business sends rejection emails, queues one
        (`rejection_email_queued`).

        A candidate who was closed out cannot be moved back into the pipeline
        here (422 `candidate_archived` — reopen them in the web app). `hired`
        needs an accepted offer and `offered` an offer in play (422
        `offer_required`).

        **Token scopes:** `write:recruiting`, and also `destructive:recruiting`
        when `candidate.status` is `rejected`, `withdrawn` or `unsuitable` —
        closing a candidate out is a final act (owner decision 2026-10-04,
        #6721). Any other update needs only the write scope.

        `PUT` is accepted as an alias of `PATCH`. An `Idempotency-Key` header
        makes a retry return the first success instead of writing again.
      security:
      - BearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Candidate ID
        schema:
          type: integer
      - name: Idempotency-Key
        in: header
        required: false
        description: A key you choose per write; a retry with the same key replays
          the stored success.
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - candidate
              properties:
                candidate:
                  type: object
                  properties:
                    status:
                      type: string
                      enum:
                      - applied
                      - screening
                      - interviewed
                      - ready_for_offer
                      - offered
                      - hired
                      - rejected
                      - withdrawn
                      - qualified
                      - potentially_qualified
                      - unsuitable
                      - interviews_in_process
                      description: "`rejected`, `withdrawn` and `unsuitable` also
                        need `destructive:recruiting`. Any other value is refused
                        (422 `invalid_status`)."
                    notes:
                      type: string
                    rejection_reason:
                      type: string
                      description: Recorded when present.
                    rejection_category:
                      type: string
                      description: Recorded together with `rejection_reason`.
      responses:
        '200':
          description: The candidate was updated.
          content:
            application/json:
              schema:
                type: object
                required:
                - candidate
                properties:
                  candidate:
                    "$ref": "#/components/schemas/RecruitingCandidateSummary"
                  rejection_email_queued:
                    type: boolean
                    description: Present when this request rejected the candidate.
        '400':
          description: '`invalid_payload` — "candidate must be an object" (the body
            has no `candidate` object, or it is not an object).'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '401':
          description: Unauthorized
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '403':
          description: '`recruiting_not_enabled`, `insufficient_permissions` (no recruiting
            access, or the token lacks `write:recruiting` — or `destructive:recruiting`
            for a close-out) or `forbidden` ("You do not have permission to update
            this candidate").'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '404':
          description: '`not_found` — "Candidate not found" in this business.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
        '422':
          description: "`invalid_status`, `candidate_archived`, `offer_required` or
            `validation_failed`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code:
                        type: string
                        description: Machine readable error code
                      message:
                        type: string
                        description: Human readable error message
                      details:
                        type: object
                        description: Additional error context
                required:
                - error
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: API token authentication
    ServiceAccountAuth:
      type: apiKey
      in: header
      name: X-API-Secret
      description: Service account secret (used with Bearer token)
    InternalAPI:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Internal API authentication with X-Internal-API header
    ChangelogSecret:
      type: apiKey
      in: header
      name: X-Changelog-Secret
      description: Changelog-specific secret for internal operations
  parameters:
    ChangeRequestId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    AccessRequestId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    Page:
      name: page
      in: query
      description: Page number for pagination
      schema:
        type: integer
        default: 1
        minimum: 1
    PerPage:
      name: per_page
      description: Number of items per page
      in: query
      schema:
        type: integer
        default: 25
        minimum: 1
        maximum: 100
    NotepadMeetingId:
      name: id
      in: path
      required: true
      description: Meeting id.
      schema:
        type: integer
    NotepadMeetingIdPath:
      name: meeting_id
      in: path
      required: true
      description: Meeting id.
      schema:
        type: integer
    NotepadSpeakerId:
      name: speaker_id
      in: path
      required: true
      description: A roster speaker id — `me`, `them`, `them_a`, `s1`.
      schema:
        type: string
        pattern: "^[a-z0-9_]{1,12}$"
    AppointmentSchedulerAppointmentId:
      name: id
      in: path
      required: true
      schema:
        type: integer
    ChatThreadsParam:
      name: threads
      in: query
      description: |
        The client draws channel threads (D1). In a channel the window is the
        timeline the web shows — everything except replies kept in their thread
        — and each root carries `thread` (reply count, last reply, repliers,
        following). In a DM or group every message comes back, replies as inline
        quotes. Wins over `include_thread_replies`. Without it nothing changes
        for an older client: a thread reply still arrives inline with
        `include_thread_replies=true`.
      schema:
        type: boolean
        default: false
    CommsHubPerPage:
      name: per_page
      in: query
      description: Rows per page. Clamped to 1..100; a blank value means 25, and a
        non-numeric value reads as 0 and is clamped to 1.
      schema:
        type: integer
        default: 25
        minimum: 1
        maximum: 100
    CommsHubIssueLang:
      name: lang
      in: query
      description: Language to return a sent issue in (e.g. `es`, `fr-CA`). Falls
        back to the original (`en`) when the issue has no reviewed translation in
        that language. Ignored for an issue that has not been sent.
      schema:
        type: string
    CompanyStoreCheckoutOrderNumber:
      name: order_number
      in: path
      required: true
      description: The ORDER NUMBER (e.g. `ORD-20260819-ABC123`) from the checkout
        write's response — the same identifier the web URLs and the orders endpoints
        use, not the numeric id and not the Stripe session id.
      schema:
        type: string
    ExportFilesId:
      name: id
      in: path
      required: true
      description: Export file ID
      schema:
        type: integer
    IntelligenceAssistantType:
      name: assistant_type
      in: query
      description: Which AI assistant to tailor to. `task_assistant`, `form_assistant`,
        `database_assistant` and `admin_assistant` add their own keys; any other value
        is accepted and adds none.
      schema:
        type: string
        default: task_assistant
        example: task_assistant
    MangoSignalAggregateWindowDays:
      name: window_days
      in: query
      required: false
      description: Days of signals to read, 1–90 (default 30); the applied value is
        echoed.
      schema:
        type: integer
        minimum: 1
        maximum: 90
    MangoSignalRowWindowDays:
      name: window_days
      in: query
      required: false
      description: Days to read, 1–365; no window when omitted. The applied value
        is echoed.
      schema:
        type: integer
        minimum: 1
        maximum: 365
    MealComplianceWaiverId:
      name: id
      in: path
      required: true
      schema:
        type: integer
      description: The waiver's id — `items[].id` from `GET /meal_compliance/waivers`.
    MealComplianceIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: 'Send one per press and repeat it on a retry: a repeated key answers
        the first response instead of acting twice. Resend signing link in particular
        has no state that stops a second reminder email.'
    MealComplianceLocale:
      name: locale
      in: query
      required: false
      schema:
        type: string
        example: es
      description: The language for the words this response writes. Omitted, the Accept-Language
        header decides, then the caller's own language.
    MuscleMemoryLessonId:
      name: id
      in: path
      required: true
      description: The lesson's id (`lesson.id`). An id that does not exist in this
        business, belongs to a sample lesson, or — for the read — to a forgotten one
        answers `404 not_found`.
      schema:
        type: integer
    PracticeHubPage:
      name: page
      in: query
      required: false
      description: Page number, from 1. A missing, non-numeric or non-positive value
        means page 1; a page past the end returns an empty list.
      schema:
        type: integer
        default: 1
    PracticeHubPerPage:
      name: per_page
      in: query
      required: false
      description: Rows per page, at most 100 (a larger value is lowered to 100).
        A missing, blank, non-numeric, zero or negative value means 25.
      schema:
        type: integer
        default: 25
        minimum: 1
        maximum: 100
    RecorderRecordingId:
      name: id
      in: path
      required: true
      description: Recording ID
      schema:
        type: integer
    RecorderParentRecordingId:
      name: recording_id
      in: path
      required: true
      description: Recording ID
      schema:
        type: integer
    RecorderIdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: A key you choose per write; a retry with the same key replays the
        first answer.
      schema:
        type: string
    RecorderCursor:
      name: cursor
      in: query
      required: false
      description: The previous page's `next_cursor`. Leave out for the first page.
      schema:
        type: integer
        minimum: 0
    SitesApprovalPageId:
      name: id
      in: path
      required: true
      description: The page's id — the Approvals row's `id`.
      schema:
        type: integer
    SitesCarePageId:
      name: page_id
      in: path
      required: true
      description: The page's id.
      schema:
        type: integer
    SitesSiteId:
      name: id
      in: path
      required: true
      description: The site's id.
      schema:
        type: integer
    SopId:
      name: id
      in: path
      required: true
      schema:
        type: integer
  responses:
    ServiceDeskProblemError:
      description: Refused
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: object
                description: "`code` is the code for the response status (400 `invalid_request`,
                  403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_failed`,
                  503 `service_unavailable`); `message` is the sentence."
                properties:
                  code:
                    type: string
                  message:
                    type: string
              errors:
                type: array
                items:
                  type: string
    ChangeRequestActionDone:
      description: Done — the change request as it now stands
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: true
              message:
                type: string
                example: Change request submitted for review.
              warnings:
                type: array
                description: Caveats — a warn-mode blackout overlap, or a timeline
                  entry that could not be written
                items:
                  type: string
              outcome:
                type: string
                nullable: true
                enum:
                - approved
                - rejected
                description: For a vote — set when this ballot carried the change
              change_request:
                "$ref": "#/components/schemas/ChangeRequestDetail"
    ChangeRequestActionRefused:
      description: |
        403 — the caller may see the change but not take this action. 409 — the change is no longer
        editable. 422 — the change is in the wrong state (e.g. "Invalid transition from draft to implementing"),
        or the input failed validation.
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: object
                description: "`code` is `forbidden` (403), `conflict` (409) or `validation_failed`
                  (422); `message` is the sentence."
                properties:
                  code:
                    type: string
                  message:
                    type: string
                    example: Invalid transition from draft to implementing
              errors:
                type: array
                items:
                  type: string
    AccessRequestDecided:
      description: Done
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: true
              message:
                type: string
                example: Access request has been rejected.
              access_request:
                "$ref": "#/components/schemas/AccessRequestDetail"
    AccessRequestForbidden:
      description: The caller can see this request but may not take this action
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/AccessRequestError"
    AccessRequestDecisionFailed:
      description: The action did not happen (e.g. the request is no longer in a state
        that allows it, or the grant failed); the current request is returned
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: object
                description: "`code` is the code for the response status (400 `invalid_request`,
                  403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_failed`,
                  503 `service_unavailable`); `message` is the sentence."
                properties:
                  code:
                    type: string
                  message:
                    type: string
              access_request:
                "$ref": "#/components/schemas/AccessRequestDetail"
    CommunicationsUnavailable:
      description: |
        Communications is off for the caller's business (`app_disabled`), not
        open to the caller — not published to them, or outside its visibility
        rules (`app_unavailable`) — or the admin's Communications access policy
        refuses this work context (`work_context_restricted`). Checked after
        the token scope and before anything else; nothing is read or written.
      content:
        application/json:
          schema:
            type: object
            required:
            - error
            properties:
              error:
                type: object
                required:
                - code
                - message
                properties:
                  code:
                    type: string
                    enum:
                    - app_disabled
                    - app_unavailable
                    - work_context_restricted
                    - forbidden
                  message:
                    type: string
                    example: Communications is not enabled for this business.
              code:
                type: string
                description: The same code
                also at the top level.:
    ServiceDeskGrievanceError:
      description: Refused
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
                example: false
              error:
                type: object
                description: "`code` is the code for the response status (400 `invalid_request`,
                  403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_failed`,
                  503 `service_unavailable`); `message` is the sentence."
                properties:
                  code:
                    type: string
                  message:
                    type: string
              errors:
                type: array
                items:
                  type: string
    ServiceDeskGrievanceUpdated:
      description: Done — the grievance as it now stands
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
              message:
                type: string
              grievance:
                "$ref": "#/components/schemas/ServiceDeskGrievanceDetail"
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    UnauthorizedError:
      description: Authentication required or token invalid
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    Forbidden:
      description: Forbidden
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    ForbiddenError:
      description: Insufficient permissions to access this resource
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NotModified:
      description: |
        Not Modified. The request carried `If-None-Match` and it matched the
        response's current ETag — the `ETag` header of the previous 200, which
        is also `_meta.http.caching.etag` in its body (weak and strong forms
        compare equal). The body is empty: keep using the copy you already
        hold. A request without `If-None-Match`, or with a stale tag, gets the
        full 200.
    ImpersonationAccountSecurityForbidden:
      description: |
        Refused for an impersonation token (error code `impersonation_cannot_change_account_security`).
        An impersonation session can act as the person for ordinary data access, but it can never change
        how they sign in or recover their account. The person must make this change from their own sign-in.
        Only impersonation tokens receive this refusal.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
          example:
            error:
              code: impersonation_cannot_change_account_security
              message: An impersonation session can't change sign-in details, passwords,
                two-factor authentication or account recovery settings; the person
                must make this change from their own sign-in.
    CredentialsWriteForbidden:
      description: |
        Refused. A token without the `write:connections` scope answers `insufficient_permissions`:
        minting, rotating and revoking a person's API tokens, signing out their sessions,
        disconnecting their assistants and managing their two-factor authentication and backup
        codes need it (the app's own sign-in and the CLI carry it; a token
        you make carries it only when you grant it). An impersonation token answers
        `impersonation_cannot_change_account_security`.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
          examples:
            missing_scope:
              value:
                error:
                  code: insufficient_permissions
                  message: 'Required scope: write:connections'
            impersonation:
              value:
                error:
                  code: impersonation_cannot_change_account_security
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NotFoundError:
      description: Resource not found
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    ValidationError:
      description: Validation error
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/ValidationErrors"
    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    RateLimited:
      description: Too many requests - rate limit exceeded
      headers:
        Retry-After:
          description: Seconds until rate limit reset
          schema:
            type: integer
        X-RateLimit-Limit:
          description: Total requests allowed per window
          schema:
            type: integer
        X-RateLimit-Remaining:
          description: Requests remaining in current window
          schema:
            type: integer
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    ApiCreditsExhausted:
      description: |
        The organization has used its plan's monthly API allowance and has no API credits left
        (error code `api_credits_exhausted`). Answered to a call made with an API token someone
        created (personal, admin, service-account or CLI) until the allowance resets on the 1st,
        credits are bought, or auto top-up buys more; never to the MangoApps apps' own sign-in or a
        signed-in session.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
          example:
            error:
              code: api_credits_exhausted
              message: This account has used its plan's API allowance for the month
                and has no API credits left. Buy platform credits, or turn on auto
                top-up, under Admin → Billing & Payments → Credits; the allowance
                resets on the 1st.
              details:
                buy_credits: "/admin/billing/purchase_credits?resource_type=api_calls"
    NotepadForbidden:
      description: The AI Notepad app is not enabled for this business, or not visible
        to the caller.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/NotepadErrorBody"
    NotepadNotFound:
      description: No such record the caller can see. Deliberately indistinguishable
        from "exists but you may not see it" — existence is not leaked.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/NotepadErrorBody"
    NotepadError:
      description: Refused, with a machine-readable code.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/NotepadErrorBody"
    NotepadValidationError:
      description: Validation failure, one entry per rejected field.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/NotepadValidationErrorBody"
    AppointmentSchedulerUnauthenticated:
      description: |
        No valid token or session (`{ "error": { "code", "message" } }`), or a
        request that authenticated without a person — "Appointment Scheduler
        requires a signed-in user" (`{ "success": false, "message" }`).
      content:
        application/json:
          schema:
            oneOf:
            - "$ref": "#/components/schemas/Error"
            - "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
    AppointmentSchedulerForbidden:
      description: |
        - `insufficient_permissions` — the token lacks the scope this endpoint names (`{ "error": { "code", "message" } }`).
        - "Appointment Scheduler is not enabled for this business" (`{ "success": false, "message" }`).
        - "You don't have access to Appointment Scheduler" — Access Management does not admit the caller (`{ "success": false, "message" }`).
      content:
        application/json:
          schema:
            oneOf:
            - "$ref": "#/components/schemas/Error"
            - "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
    AppointmentSchedulerCannotChange:
      description: |
        Every refusal `AppointmentSchedulerForbidden` lists, and also "Only
        this appointment's host, its booking page's owner, or an Appointment
        Scheduler admin can change it" — the caller can read the appointment
        (a collective meeting's co-host) but not change it.
      content:
        application/json:
          schema:
            oneOf:
            - "$ref": "#/components/schemas/Error"
            - "$ref": "#/components/schemas/AppointmentSchedulerRefusal"
    AskAiPaymentRequired:
      description: |
        `upgrade_required`: the organization's plan does not include Ask AI
        (`error.details` carries `feature`, `required_tier`,
        `required_tier_name`, `current_tier`, `current_tier_name`). Or
        `api_credits_exhausted`: a call with a created API token after the
        month's API allowance is used up.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    AskAiReadRefused:
      description: |
        `app_disabled`: Ask AI is not enabled for this business or not open to
        this person. `extension_disabled`: a Chrome extension request while the
        organization has the extension turned off. `insufficient_permissions`:
        the API token lacks `read:chat` (`read:own_chat`, `read:all` and `admin`
        also pass; signed-in sessions are not checked).
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    AskAiWriteRefused:
      description: |
        `app_disabled`: Ask AI is not enabled for this business or not open to
        this person. `extension_disabled`: a Chrome extension request while the
        organization has the extension turned off. `insufficient_permissions`:
        the API token lacks `write:chat` (`write:own_chat` and `admin` also
        pass; signed-in sessions are not checked).
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    AskAiEntitlementUnavailable:
      description: "`entitlement_unavailable`: the check of the organization's plan
        failed — try again in a minute."
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    BulkOperationQueued:
      description: The operation was saved and queued.
      content:
        application/json:
          schema:
            type: object
            required:
            - bulk_operation
            - job_id
            - status_url
            properties:
              bulk_operation:
                type: object
                description: The saved operation record, `metadata` (the submitted
                  items and options) included.
              job_id:
                type: string
                format: uuid
              status_url:
                type: string
                example: "/api/v1/bulk_operations/status/0b6f3c3e-8d5a-4a8e-9f0e-2c1d7a9e4b11"
    BulkOperationForbidden:
      description: '`insufficient_permissions` in the standard error envelope (the
        token lacks the data type''s write scope, or `destructive:scheduling` for
        a final act), or `{ "error": "…" }`: not an admin or manager, Shifts & Scheduling
        not enabled, an administrator needed for demand signals and forecasts, or
        items outside the caller''s team or locations ("No items were processed").'
      content:
        application/json:
          schema:
            oneOf:
            - type: object
              properties:
                error:
                  type: object
                  properties:
                    code:
                      type: string
                      description: Machine readable error code
                    message:
                      type: string
                      description: Human readable error message
                    details:
                      type: object
                      description: Additional error context
              required:
              - error
            - "$ref": "#/components/schemas/BulkOperationRefusal"
    BulkOperationRefused:
      description: '`{ "error": "No items provided" }`, `{ "errors": [...] }` when
        the operation fails validation (an unknown `data_type` or `operation_type`),
        or `{ "error": "No processor is available for ''…''" }`.'
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
              errors:
                type: array
                items:
                  type: string
    CommsHubPublicationsForbidden:
      description: |
        Every refusal is the `{"error": {"code", "message"}}` envelope:
        - `insufficient_permissions` — the token lacks `read:broadcasts` ("Required scope: read:broadcasts").
        - `forbidden` — "Comms Hub is not enabled for this business." (the Communications app is off), "You don't have access to Comms Hub." (the app is not published to the caller), "No business context for this request.", or "Publications is disabled for this business." (an admin turned the newsletters module, `enable_publications`, off).
        - `work_context_restricted` — an admin's Communications access policy refuses the caller's work context; the body also carries `code` and `error_detail` at the top level.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    CommsHubAudiencesForbidden:
      description: |
        Every refusal is the `{"error": {"code", "message"}}` envelope:
        - `insufficient_permissions` — the token lacks `read:broadcasts` ("Required scope: read:broadcasts").
        - `forbidden` — "You don't have permission to view audiences." (the caller may not publish Communications), "Audiences is disabled for this business." (an admin turned the Audiences module, `enable_audiences`, off), "Comms Hub is not enabled for this business.", "You don't have access to Comms Hub." or "No business context for this request.".
        - `work_context_restricted` — an admin's Communications access policy refuses the caller's work context; the body also carries `code` and `error_detail` at the top level.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    ExportFilesQueued:
      description: Saved and queued; poll `status_url` until `status` is `completed`
      content:
        application/json:
          schema:
            type: object
            required:
            - export_file
            - job_id
            - status_url
            properties:
              export_file:
                "$ref": "#/components/schemas/ExportFilesExportFile"
              job_id:
                type: string
                format: uuid
              status_url:
                type: string
                example: "/api/v1/export_files/status/0b6f1c1e-3c55-4f0b-9d0e-6a3c1f2f9a10"
    ExportFilesNotQueued:
      description: |
        Nothing was queued. Either the export was not saved — `{"errors":
        ["Export type is not included in the list"]}`, "Format is not
        included in the list", … — or no export job exists for its type: the
        row is saved as `failed` and returned beside `{"error": {"code":
        "validation_failed", "message": "No export is available for '…'"}}`
        as `export_file`. Or the request sent a non-empty `filters` or
        `options`, refused before anything is saved: `{"error": {"code":
        "unsupported_parameter", "message": "Exports take no filters: the
        file holds every row of its type. Send only the format, and filter
        the file after downloading it."}}`.
      content:
        application/json:
          schema:
            oneOf:
            - "$ref": "#/components/schemas/ExportFilesValidationMessages"
            - allOf:
              - "$ref": "#/components/schemas/Error"
              - type: object
                properties:
                  export_file:
                    "$ref": "#/components/schemas/ExportFilesExportFile"
    ExportFilesNotScheduled:
      description: |
        `service_unavailable` — the job could not be queued ("Could not
        schedule the export. Please try again."). The row is saved as
        `failed` and returned beside the error as `export_file`.
      content:
        application/json:
          schema:
            allOf:
            - "$ref": "#/components/schemas/Error"
            - type: object
              properties:
                export_file:
                  "$ref": "#/components/schemas/ExportFilesExportFile"
    IntelligenceBusinessContextRequired:
      description: '`invalid_request` — "Business context required": the request resolved
        no business.'
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    IntelligenceRateLimited:
      description: '`rate_limited` — "Rate limit exceeded. Please try again later.":
        the caller has made 100 `smart_defaults` / `contextual_suggestions` calls
        without an hour''s gap. `retry_after` is in seconds.'
      content:
        application/json:
          schema:
            allOf:
            - "$ref": "#/components/schemas/Error"
            - type: object
              properties:
                retry_after:
                  type: integer
                  example: 3600
    MangoSignalForbidden:
      description: "`insufficient_permissions` (the token lacks the scope), `mango_signal_disabled`,
        or the endpoint's role rule (`manager_access_required`, `admin_access_required`)."
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/MangoSignalRefusal"
    MealComplianceWaiverActionDone:
      description: 'Done: the sentence the web page flashes, its `tone` (`warning`
        when the write landed but the employee could not be told — tell them directly),
        and the waiver page as it now stands — the same body as `GET /meal_compliance/waivers/{id}`,
        so the client redraws without a second request.'
      content:
        application/json:
          schema:
            allOf:
            - type: object
              properties:
                message:
                  type: string
                tone:
                  type: string
                  enum:
                  - success
                  - warning
            - "$ref": "#/components/schemas/MealComplianceWaiverDetails"
          example:
            message: Waiver has been invalidated and Omar Haddad has been notified.
            tone: success
            waiver:
              id: 904
              status: invalidated
              status_label: Invalidated
              tone: danger
            permissions:
              can_invalidate: false
              can_start_new_waiver: true
            actions: []
    MealComplianceWaiverActionForbidden:
      description: The token lacks the write scope (`insufficient_permissions`), the
        app is not open to the caller, the waiver belongs to someone the caller may
        not see, or the caller may see it but does not manage it — an employee on
        their own waiver, a line manager on their own, any own-tier token (`forbidden`).
    MuscleMemoryReadForbidden:
      description: |
        - `insufficient_permissions` — the token lacks `read:muscle_memory` ("Required scope: read:muscle_memory").
        - `app_disabled` — Muscle Memory is not enabled for the business ("Muscle Memory is not enabled for this business.").
        - `forbidden` — Muscle Memory is not published to the caller ("You don't have access to Muscle Memory."), or, on the list, `status=recheck` from a caller who does not hold the curate capability ("You don't have permission to curate business lessons for this business.").
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    MuscleMemoryCurateForbidden:
      description: |
        - `insufficient_permissions` — the token lacks the scope this call needs ("Required scope: write:muscle_memory", or "Required scope: destructive:muscle_memory" for DELETE).
        - `app_disabled` — Muscle Memory is not enabled for the business ("Muscle Memory is not enabled for this business.").
        - `forbidden` — Muscle Memory is not published to the caller ("You don't have access to Muscle Memory."), or the caller does not hold the curate capability ("You don't have permission to curate business lessons for this business."; for `POST /muscle-memory/lessons` the message adds that a lesson can be suggested from the Muscle Memory page instead).

        The capability is checked before the id is looked up, so a caller who may not curate gets `403` whatever id they send.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    MuscleMemoryLessonNotFound:
      description: '`not_found` — no such lesson in this business ("Lesson not found."
        or "Memory not found."), or it is a sample lesson. For `GET`, a forgotten
        lesson is not found either.

        '
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    MuscleMemoryInternalError:
      description: '`internal_server_error` — "Something went wrong. Please try again."
        The details are logged, never returned. Nothing was changed by a failed call
        except where the response says otherwise.'
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NewsFeedFeedNotFound:
      description: '`not_found` "Feed not found" — no such post in the business, or
        a draft or scheduled post the caller is not preparing.'
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NewsFeedMediaNotFound:
      description: '`not_found` — "Feed not found" (as above) or "Media not found"
        (no live attachment with this id on the post).'
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NewsFeedMediaReadForbidden:
      description: |
        - `insufficient_permissions` — the token lacks `read:news_feed`.
        - `not_authorized` — "You do not have access to this feed": the caller is not in the post's audience and may not view any post.
        - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NewsFeedMediaWriteForbidden:
      description: |
        - `insufficient_permissions` — the token lacks `write:news_feed`.
        - `feed_not_editable` — the caller may not change this post's attachments. The message says why: "You can't change the attachments on someone else's post.", "Attachments on this post can no longer be changed." (content editing is off, or someone acknowledged it), or "Only <name> and the person writing it for them can change this post's attachments before it goes out."
        - `app_disabled` / `app_unavailable` / `work_context_restricted` / `forbidden` — the Communications app door (see `CommunicationsUnavailable`).
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    NewsFeedMediaRefused:
      description: |
        The upload was refused (`{"error": {"code", "message"}}`):
        `invalid` (a missing parameter or an unknown `media_type`),
        `media_uploads_disabled`, `gif_uploads_disabled`,
        `attachment_cap_reached` (the post already has 10 attachments),
        `file_too_large`, `blocked_mime`, `mime_not_allowed`,
        `blocked_extension`, `alt_text_required`, `direct_upload_unsupported`
        (the JSON leg on a deployment that does not store files in S3),
        `storage_unavailable`; for a comment's file `invalid_parent_type`,
        `comment_not_found`, `comment_feed_mismatch`, `comment_deleted`,
        `not_comment_author`, `edit_window_expired`,
        `comment_attachment_cap_reached`.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
    RecorderRecordingNotFound:
      description: "`recording_not_found` — no such recording visible to the caller."
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    RecorderIdempotencyConflict:
      description: "`idempotency_conflict` — this Idempotency-Key was used for a different
        request; or `idempotency_in_progress` — a request with this key is still running
        (retry after the `Retry-After` seconds to get its answer)."
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    RecorderMediaTooLarge:
      description: "`file_too_large` — over the workspace's upload cap; `error.details`
        carries `max_bytes` and `allowed_types`."
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    RecorderMediaTypeUnsupported:
      description: "`unsupported_media_type` — not a WebM, MP4 or QuickTime video
        or a PNG or JPEG image (by its declared type, or by its bytes); `error.details`
        carries `max_bytes` and `allowed_types`."
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code:
                    type: string
                    description: Machine readable error code
                  message:
                    type: string
                    description: Human readable error message
                  details:
                    type: object
                    description: Additional error context
            required:
            - error
    RecorderProcessingStarted:
      description: The media is attached and processing has started.
      content:
        application/json:
          schema:
            type: object
            required:
            - recording
            - processing
            properties:
              recording:
                "$ref": "#/components/schemas/RecorderRecording"
              processing:
                type: object
                properties:
                  started:
                    type: boolean
                    example: true
    SitesApprovalRevisionMissing:
      description: "`parameter_missing` — `review_revision` was not sent."
      content:
        application/json:
          example:
            error:
              code: parameter_missing
              message: Send review_revision — the row's review.revision — with a decision.
              details:
                parameter: review_revision
    SitesApprovalDecisionForbidden:
      description: "`own_request` — the caller asked for this approval, so someone
        else decides it (they can withdraw it instead); `on_hold` — the page's site
        is in the Trash; `not_approver` — only the approver it was sent to, or an
        admin, can decide it; `approvals_disabled` — page approvals are turned off
        for the company; `insufficient_permissions` — the token lacks `write:sites`;
        `app_disabled` / `app_access_denied` — the Sites app is off for the company
        or not open to the caller."
      content:
        application/json:
          example:
            error:
              code: own_request
              message: You asked for this approval, so someone else has to decide
                it. You can withdraw the request instead.
    SitesApprovalDecisionConflict:
      description: "`not_pending` — the page isn't waiting for approval (already decided
        or withdrawn); `review_changed` — the request changed after the caller loaded
        it. Reload the Approvals list and look again."
      content:
        application/json:
          example:
            error:
              code: review_changed
              message: 'Could not approve: This approval request changed. Reload the
                page and check the latest draft before deciding.'
    SitesCareTransferPending:
      description: "`transfer_pending` — the page or its site is still being imported;
        the message says where the import stands."
      content:
        application/json:
          example:
            error:
              code: transfer_pending
              message: This import is still being prepared. Try again once it finishes.
    SitesCareRefused:
      description: "`access_denied` — the caller can neither open nor edit the page,
        so nothing else about it is said; `not_allowed` — only the site's owners and
        editors and admins can do this; `site_trashed` (extend) — the site is in Trash;
        `content_sync_disabled` (source refresh); `insufficient_permissions` — the
        token lacks the scope."
      content:
        application/json:
          example:
            error:
              code: not_allowed
              message: Only this site's owners and editors can do this.
  schemas:
    ServiceDeskProblemUser:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    ServiceDeskProblemIncident:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
        priority:
          type: string
        requester:
          type: string
        created_at:
          type: string
          format: date-time
    ServiceDeskProblemChangeRequest:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        change_number:
          type: string
        title:
          type: string
        status:
          type: string
    ServiceDeskProblemRef:
      type: object
      properties:
        id:
          type: integer
        problem_number:
          type: string
        title:
          type: string
        status:
          type: string
    ServiceDeskProblemInput:
      type: object
      properties:
        title:
          type: string
          maxLength: 255
          description: Required on create
        description:
          type: string
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        category:
          type: string
          enum:
          - network
          - hardware
          - software
          - security
          - process
          - other
        assignee_id:
          type: integer
          nullable: true
        service_department_id:
          type: integer
          nullable: true
    ServiceDeskProblemSummary:
      type: object
      properties:
        id:
          type: integer
        problem_number:
          type: string
          example: PRB-2026-0001
        title:
          type: string
        status:
          type: string
        priority:
          type: string
        category:
          type: string
          nullable: true
        known_error:
          type: boolean
        linked_incident_count:
          type: integer
          description: Incidents this caller may see — grievances they cannot view
            are not counted
        duplicate_of_id:
          type: integer
          nullable: true
          description: Set when the problem was closed as a duplicate of another
        assignee:
          "$ref": "#/components/schemas/ServiceDeskProblemUser"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ServiceDeskProblemDetail:
      allOf:
      - "$ref": "#/components/schemas/ServiceDeskProblemSummary"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          identified_by:
            "$ref": "#/components/schemas/ServiceDeskProblemUser"
          service_department:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          root_cause:
            type: string
            nullable: true
          root_cause_method:
            type: string
            nullable: true
          root_cause_found_at:
            type: string
            format: date-time
            nullable: true
          workaround:
            type: string
            nullable: true
          known_error_description:
            type: string
            nullable: true
          known_error_since:
            type: string
            format: date-time
            nullable: true
          resolution:
            type: string
            nullable: true
          resolved_at:
            type: string
            format: date-time
            nullable: true
          closure_notes:
            type: string
            nullable: true
          closed_at:
            type: string
            format: date-time
            nullable: true
          linked_incident_count:
            type: integer
            description: Incidents this caller may see
          linked_incidents:
            type: array
            items:
              "$ref": "#/components/schemas/ServiceDeskProblemIncident"
          configuration_items:
            type: array
            nullable: true
            description: Linked configuration items; null while the tenant has the
              CMDB module turned off (CI links are refused then).
            items:
              type: object
              properties:
                id:
                  type: integer
                ci_number:
                  type: string
                name:
                  type: string
                ci_type:
                  type: string
                criticality:
                  type: string
                status:
                  type: string
          investigation_entries:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                entry_type:
                  type: string
                  nullable: true
                content:
                  type: string
                user:
                  "$ref": "#/components/schemas/ServiceDeskProblemUser"
                created_at:
                  type: string
                  format: date-time
          timeline:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                action:
                  type: string
                from_status:
                  type: string
                  nullable: true
                to_status:
                  type: string
                  nullable: true
                details:
                  type: string
                  nullable: true
                user:
                  "$ref": "#/components/schemas/ServiceDeskProblemUser"
                created_at:
                  type: string
                  format: date-time
          fix_change_request:
            "$ref": "#/components/schemas/ServiceDeskProblemChangeRequest"
          duplicate_of:
            allOf:
            - "$ref": "#/components/schemas/ServiceDeskProblemRef"
            nullable: true
            description: The problem this one was closed as a duplicate of
          duplicates:
            type: array
            description: Problems closed as duplicates of this one
            items:
              "$ref": "#/components/schemas/ServiceDeskProblemRef"
          permissions:
            type: object
            properties:
              can_edit:
                type: boolean
              available_events:
                type: array
                items:
                  type: string
              can_publish_known_error:
                type: boolean
              can_unpublish_known_error:
                type: boolean
              can_create_change_request:
                type: boolean
              can_close_as_duplicate:
                type: boolean
                description: Close as a duplicate is offered (needs destructive:service_desk)
              can_reopen:
                type: boolean
                description: Reopen is offered — a closed problem
                a Service Desk admin:
    ServiceDeskProblemEnvelope:
      type: object
      properties:
        success:
          type: boolean
        message:
          type: string
        problem:
          "$ref": "#/components/schemas/ServiceDeskProblemDetail"
    ChangeRequestPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    ChangeRequestInput:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        change_type:
          type: string
          enum:
          - standard
          - normal
          - emergency
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        implementation_plan:
          type: string
        rollback_plan:
          type: string
        test_plan:
          type: string
        communication_plan:
          type: string
        reason_for_change:
          type: string
        scheduled_start_at:
          type: string
          format: date-time
        scheduled_end_at:
          type: string
          format: date-time
        emergency_justification:
          type: string
        service_department_id:
          type: integer
        support_team_id:
          type: integer
        related_incident_id:
          type: integer
        assignee_id:
          type: integer
        cab_id:
          type: integer
    ChangeRequestSummary:
      type: object
      properties:
        id:
          type: integer
        change_number:
          type: string
          example: RFC-2026-0001
        title:
          type: string
        status:
          type: string
          enum:
          - draft
          - submitted
          - pending_review
          - approved
          - scheduled
          - implementing
          - implemented
          - closed
          - failed
          - cancelled
        status_label:
          type: string
          example: Pending Review
          description: 'The label the web badge shows: `submitted` reads "Pending
            Review" ("Returned" once a CAB rejected it), `pending_review` reads "In
            CAB Review"'
        change_type:
          type: string
          enum:
          - standard
          - normal
          - emergency
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        risk_level:
          type: string
          nullable: true
          enum:
          - low
          - medium
          - high
          - critical
        risk_score:
          type: integer
          nullable: true
        requester:
          "$ref": "#/components/schemas/ChangeRequestPerson"
        assignee:
          "$ref": "#/components/schemas/ChangeRequestPerson"
        cab:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        scheduled_start_at:
          type: string
          format: date-time
          nullable: true
        scheduled_end_at:
          type: string
          format: date-time
          nullable: true
        review_sla_due_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true
    ChangeRequestDetail:
      allOf:
      - "$ref": "#/components/schemas/ChangeRequestSummary"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          reason_for_change:
            type: string
            nullable: true
          implementation_plan:
            type: string
            nullable: true
          rollback_plan:
            type: string
            nullable: true
          test_plan:
            type: string
            nullable: true
          communication_plan:
            type: string
            nullable: true
          emergency_justification:
            type: string
            nullable: true
          rejection_reason:
            type: string
            nullable: true
          closure_code:
            type: string
            nullable: true
          closure_notes:
            type: string
            nullable: true
          lessons_learned:
            type: string
            nullable: true
          approval_rule:
            type: string
            nullable: true
          emergency_bypass:
            type: boolean
          review_sla_breached:
            type: boolean
          risk_factors:
            type: array
            items:
              type: string
          service_department_id:
            type: integer
            nullable: true
          support_team_id:
            type: integer
            nullable: true
          related_incident_id:
            type: integer
            nullable: true
          actual_start_at:
            type: string
            format: date-time
            nullable: true
          actual_end_at:
            type: string
            format: date-time
            nullable: true
          approved_at:
            type: string
            format: date-time
            nullable: true
          rejected_at:
            type: string
            format: date-time
            nullable: true
          closed_at:
            type: string
            format: date-time
            nullable: true
          approval_summary:
            type: object
            properties:
              total:
                type: integer
              decided:
                type: integer
              pending:
                type: integer
              approved:
                type: integer
              rejected:
                type: integer
              deferred:
                type: integer
          approvals:
            type: array
            items:
              type: object
              properties:
                approver:
                  "$ref": "#/components/schemas/ChangeRequestPerson"
                decision:
                  type: string
                  nullable: true
                  enum:
                  - approved
                  - rejected
                  - deferred
                  - more_info_requested
                comments:
                  type: string
                  nullable: true
                decided_at:
                  type: string
                  format: date-time
                  nullable: true
          configuration_items:
            type: array
            nullable: true
            description: Linked configuration items; null while the tenant has the
              CMDB module turned off (CI links are refused then).
            items:
              type: object
              properties:
                id:
                  type: integer
                ci_number:
                  type: string
                name:
                  type: string
                ci_type:
                  type: string
                criticality:
                  type: string
                status:
                  type: string
          attachments_count:
            type: integer
          timeline:
            type: array
            description: Newest 200 entries, oldest first
            items:
              type: object
              properties:
                action:
                  type: string
                  nullable: true
                from_status:
                  type: string
                  nullable: true
                to_status:
                  type: string
                  nullable: true
                from_status_label:
                  type: string
                  nullable: true
                to_status_label:
                  type: string
                  nullable: true
                details:
                  type: string
                  nullable: true
                user:
                  "$ref": "#/components/schemas/ChangeRequestPerson"
                created_at:
                  type: string
                  format: date-time
          timeline_truncated:
            type: boolean
          permissions:
            type: object
            description: What the caller may do right now
            properties:
              can_edit:
                type: boolean
              can_make_emergency:
                type: boolean
                description: May set change_type to emergency in an edit — the requester
                  or an admin; other staff editing a colleague's draft may not (an
                  emergency change is approved the moment it is submitted). A PATCH
                  that tries answers 403.
              can_submit:
                type: boolean
              can_resubmit:
                type: boolean
              can_review:
                type: boolean
                description: May approve or reject (the review). Reject also needs
                  destructive:service_desk — read can_reject for it.
              can_reject:
                type: boolean
                description: 'May reject the change: can_review, and a token holding
                  destructive:service_desk (#7077).'
              can_vote:
                type: boolean
              can_route_to_cab:
                type: boolean
              can_assign:
                type: boolean
              can_schedule:
                type: boolean
              can_start_implementation:
                type: boolean
              can_complete_implementation:
                type: boolean
              can_fail_implementation:
                type: boolean
              can_close:
                type: boolean
              can_cancel:
                type: boolean
    AccessRequestPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
    AccessRequestSummary:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - executed
          - cancelled
          - expired
        target_type:
          type: string
          example: Location
        target_type_label:
          type: string
          example: Location
          description: Display label — Workspace reads Project, AssetPro::Category
            reads Equipment
        target_id:
          type: integer
        target_name:
          type: string
        requested_role:
          type: string
          nullable: true
        requester:
          "$ref": "#/components/schemas/AccessRequestPerson"
        approver:
          "$ref": "#/components/schemas/AccessRequestPerson"
        created_at:
          type: string
          format: date-time
        can_approve:
          type: boolean
          description: The caller can approve or reject this request now
    AccessRequestDetail:
      allOf:
      - "$ref": "#/components/schemas/AccessRequestSummary"
      - type: object
        properties:
          justification:
            type: string
          approval_notes:
            type: string
            nullable: true
          rejection_reason:
            type: string
            nullable: true
          executed_by:
            "$ref": "#/components/schemas/AccessRequestPerson"
          help_request_id:
            type: integer
            nullable: true
            description: The linked Service Desk ticket
          approved_at:
            type: string
            format: date-time
            nullable: true
          rejected_at:
            type: string
            format: date-time
            nullable: true
          executed_at:
            type: string
            format: date-time
            nullable: true
          cancelled_at:
            type: string
            format: date-time
            nullable: true
          expires_at:
            type: string
            format: date-time
            nullable: true
          permissions:
            type: object
            description: The actions the web detail page offers this caller
            properties:
              can_approve:
                type: boolean
              can_reject:
                type: boolean
              can_cancel:
                type: boolean
              can_resubmit:
                type: boolean
          audit_trail:
            type: array
            items:
              type: object
              properties:
                capability:
                  type: string
                status:
                  type: string
                created_at:
                  type: string
                  format: date-time
    AccessRequestWriteResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        message:
          type: string
        access_request:
          "$ref": "#/components/schemas/AccessRequestDetail"
    AccessRequestError:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          description: "`code` is the root `code` when the refusal carries one, else
            the code for the response status (400 `invalid_request`, 403 `forbidden`,
            404 `not_found`, 422 `validation_failed`); `message` is the sentence."
          properties:
            code:
              type: string
            message:
              type: string
        errors:
          type: array
          items:
            type: string
        code:
          type: string
          nullable: true
          description: The refusal's own code, when it has one (also `error.code`).
    ConfigurationItemRef:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        ci_number:
          type: string
          example: CI-00012
        ci_type:
          type: string
        status:
          type: string
        criticality:
          type: string
    CmdbPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    ConfigurationItemSummary:
      allOf:
      - "$ref": "#/components/schemas/ConfigurationItemRef"
      - type: object
        properties:
          display_name:
            type: string
            example: Web Server 01 (CI-00012)
          environment:
            type: string
            nullable: true
          environment_label:
            type: string
            example: Production
          retired:
            type: boolean
          owner:
            "$ref": "#/components/schemas/CmdbPerson"
          service_department:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          updated_at:
            type: string
            format: date-time
    ConfigurationItemDetail:
      allOf:
      - "$ref": "#/components/schemas/ConfigurationItemSummary"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          location:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          asset:
            type: object
            nullable: true
            description: The Asset Pro asset this CI is
            properties:
              id:
                type: integer
              name:
                type: string
              asset_number:
                type: string
          attributes_data:
            type: object
            additionalProperties: true
            description: Custom attributes (key → value)
          created_at:
            type: string
            format: date-time
    ConfigurationItemRelationship:
      type: object
      properties:
        id:
          type: integer
        relationship_type:
          type: string
          enum:
          - depends_on
          - runs_on
          - part_of
          - connects_to
          - uses
        label:
          type: string
          description: Worded from this CI's side, e.g. "Depends on" upstream, "Depended
            on by" downstream
        notes:
          type: string
          nullable: true
        configuration_item:
          allOf:
          - "$ref": "#/components/schemas/ConfigurationItemRef"
          - type: object
            properties:
              display_name:
                type: string
              owner:
                "$ref": "#/components/schemas/CmdbPerson"
    ConfigurationItemInput:
      type: object
      properties:
        name:
          type: string
          maxLength: 255
        description:
          type: string
        ci_type:
          type: string
          enum:
          - service
          - server
          - application
          - database
          - network
          - storage
          - other
        status:
          type: string
          enum:
          - active
          - inactive
          - planned
          - retired
        environment:
          type: string
          enum:
          - production
          - staging
          - development
          - dr
        criticality:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        owner_id:
          type: integer
        service_department_id:
          type: integer
        location_id:
          type: integer
        asset_pro_asset_id:
          type: integer
        attributes_data:
          type: object
          additionalProperties: true
          description: A JSON object under 10KB (a JSON string of one is accepted
            too). `{}` clears.
    ConfigurationItemOptions:
      type: object
      properties:
        ci_types:
          type: array
          items:
            type: string
        statuses:
          type: array
          items:
            type: string
        environments:
          type: array
          items:
            type: string
        criticalities:
          type: array
          items:
            type: string
        relationship_types:
          type: array
          items:
            type: string
        relationship_directions:
          type: array
          items:
            type: object
            properties:
              value:
                type: string
                enum:
                - child
                - parent
              label:
                type: string
                example: Child of this CI
    CmdbValidationError:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          description: "`code` is the code for the response status (400 `invalid_request`,
            403 `forbidden`, 404 `not_found`, 409 `conflict`, 422 `validation_failed`,
            503 `service_unavailable`); `message` is the sentence."
          properties:
            code:
              type: string
            message:
              type: string
        errors:
          type: array
          items:
            type: string
    ServiceDeskGrievancePerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    ServiceDeskGrievanceStageRef:
      type: object
      nullable: true
      properties:
        key:
          type: string
        name:
          type: string
    ServiceDeskGrievanceInput:
      type: object
      properties:
        title:
          type: string
          description: Required on create
        description:
          type: string
          description: Required on create
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        custom_fields:
          type: object
          additionalProperties: true
          description: Answers keyed by question field_name
        assignee_id:
          type: integer
          nullable: true
          description: Handlers only
        support_team_id:
          type: integer
          nullable: true
          description: Handlers only
        service_department_id:
          type: integer
          nullable: true
          description: Handlers only
        anonymous:
          type: boolean
          description: Always refused when true
    ServiceDeskGrievanceComment:
      type: object
      properties:
        id:
          type: integer
        content:
          type: string
        author:
          "$ref": "#/components/schemas/ServiceDeskGrievancePerson"
        internal:
          type: boolean
        created_at:
          type: string
          format: date-time
    ServiceDeskGrievanceSummary:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
        stage:
          "$ref": "#/components/schemas/ServiceDeskGrievanceStageRef"
        stage_due_at:
          type: string
          format: date-time
          nullable: true
        stage_overdue:
          type: boolean
        grievant_name:
          type: string
        grievance_type:
          type: string
          nullable: true
        filing_date:
          type: string
          nullable: true
        submitted_at:
          type: string
          format: date-time
        priority:
          type: string
          description: Absent while the tenant hides priority from this caller
        assignee:
          "$ref": "#/components/schemas/ServiceDeskGrievancePerson"
    ServiceDeskGrievanceDetail:
      allOf:
      - "$ref": "#/components/schemas/ServiceDeskGrievanceSummary"
      - type: object
        properties:
          description:
            type: string
          requester_name:
            type: string
            nullable: true
          support_team:
            "$ref": "#/components/schemas/ServiceDeskGrievancePerson"
          stages:
            type: array
            items:
              type: object
              properties:
                key:
                  type: string
                name:
                  type: string
                state:
                  type: string
                  enum:
                  - completed
                  - current
                  - upcoming
          custom_fields:
            type: array
            items:
              type: object
              properties:
                key:
                  type: string
                label:
                  type: string
                value: {}
                display_value:
                  type: string
          stage_history:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                content:
                  type: string
                  description: Staff notes included for handlers only
                from_stage:
                  "$ref": "#/components/schemas/ServiceDeskGrievanceStageRef"
                to_stage:
                  "$ref": "#/components/schemas/ServiceDeskGrievanceStageRef"
                author:
                  "$ref": "#/components/schemas/ServiceDeskGrievancePerson"
                created_at:
                  type: string
                  format: date-time
          comments:
            type: array
            items:
              "$ref": "#/components/schemas/ServiceDeskGrievanceComment"
          comments_truncated:
            type: boolean
            description: Only the newest 200 comments are included
          attachments:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                filename:
                  type: string
                content_type:
                  type: string
                byte_size:
                  type: integer
                url:
                  type: string
                  description: Download link
                  expires after an hour:
          allowed_transitions:
            type: array
            description: Handlers only
            items:
              "$ref": "#/components/schemas/ServiceDeskGrievanceStageRef"
          revert_targets:
            type: array
            description: Admins only
            items:
              "$ref": "#/components/schemas/ServiceDeskGrievanceStageRef"
          permissions:
            type: object
            properties:
              can_edit:
                type: boolean
              can_advance_stage:
                type: boolean
              can_revert_stage:
                type: boolean
              can_comment:
                type: boolean
              can_add_internal_note:
                type: boolean
              can_upload_document:
                type: boolean
              can_remove_document:
                type: boolean
    BusinessDiscoveryResult:
      type: object
      description: |
        Tenants the submitted email address can sign in to. Always returned with
        a 200, including when the address is unknown or has no active tenants —
        in both of those cases `businesses` is an empty array and `total_count`
        is 0. The endpoint is unauthenticated, so it deliberately does NOT
        distinguish "no such account" from "account with no active tenants":
        doing so would make it an account-existence oracle.
      properties:
        businesses:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 456
              name:
                type: string
                example: Office Chat Solutions
              subdomain:
                type: string
                example: officechat
              logo_url:
                type: string
                nullable: true
                description: |
                  Absolute logo URL, or null when the tenant has no logo attached.
                  Served from the tenant's OWN host (matching api_base_url), not
                  the host that answered this request.
                example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/logo.png
              api_base_url:
                type: string
                description: |
                  Origin the client should send the subsequent login request to.
                  Already carries the environment suffix (-dev / -qa / -staging;
                  none in production), so clients must not rebuild the host from
                  `subdomain` themselves.
                example: https://officechat.workforce.mangoapps.com
        total_count:
          type: integer
          example: 2
    SupportTicketSummary:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
        status_color_class:
          type: string
          description: Bootstrap badge class mapped from status
          example: bg-warning
        priority:
          type: string
          description: Omitted when the tenant turned on "Hide priority from requester"
            and the caller is the requester, or is neither Service Desk staff nor
            a "view all tickets" holder.
        priority_color_class:
          type: string
          description: Bootstrap badge class mapped from priority. Omitted whenever
            `priority` is.
          example: bg-danger
        escalated:
          type: boolean
          description: Escalated and not since de-escalated — stays true after the
            ticket is resolved or closed (see escalation_active)
        escalation_active:
          type: boolean
          description: Escalated and not yet finished — what the web badges as "Escalated"
        request_type:
          type: string
        requester:
          type: object
          properties:
            id:
              type: integer
              nullable: true
              description: Null on an anonymous ticket (name reads "Anonymous")
            name:
              type: string
            email:
              type: string
              nullable: true
              description: Null on an anonymous ticket, or a requester with no email
                address
        assignee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        due_at:
          type: string
          format: date-time
          nullable: true
        is_overdue:
          type: boolean
          nullable: true
        sla_breached:
          type: boolean
          nullable: true
          description: True when the SLA deadline (sla_due_at) has passed and the
            ticket is still open. Null for a caller who is not shown internal fields
            (e.g. the requester).
        sla_due_at:
          type: string
          format: date-time
          nullable: true
          description: SLA deadline computed from priority and assignment time. An
            internal field — shown to Service Desk staff, managers, the assignee and
            the approver; null for everyone else.
        response_expected_at:
          type: string
          format: date-time
          nullable: true
          description: When the service desk expects to resolve the ticket (the priority's
            time-to-resolve target) — the "Resolution expected by" time the web shows
            a requester instead of the SLA deadline. Set only for a caller who is
            not shown sla_due_at, only while the ticket is open and not waiting on
            approval, and only when the desk has SLA on.
        rejected_at:
          type: string
          format: date-time
          nullable: true
          description: When a rejected request was turned down; null unless the status
            is rejected.
        rejection_reason:
          type: string
          nullable: true
          description: Why a rejected request was turned down — what the ticket page's
            "Request Rejected" banner shows.
    SupportTicketDetail:
      allOf:
      - "$ref": "#/components/schemas/SupportTicketSummary"
      - type: object
        properties:
          description:
            type: string
          escalated_at:
            type: string
            format: date-time
            nullable: true
          escalation_reason:
            type: string
            nullable: true
            description: Service Desk staff only; null for everyone else
          approved_by:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          escalated_to:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          rejected_by:
            type: object
            nullable: true
            description: Who turned a rejected request down; null unless the status
              is rejected.
            properties:
              id:
                type: integer
              name:
                type: string
          location:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          urgent:
            type: boolean
            nullable: true
          satisfaction_rating:
            type: integer
            nullable: true
            minimum: 1
            maximum: 5
          satisfaction_feedback:
            type: string
            nullable: true
          comments_truncated:
            type: boolean
            description: True when `comments` holds only the newest 50 of `comments_count`;
              GET /:id/comments has the whole thread
          resolution_notes:
            type: string
            nullable: true
          resolved_at:
            type: string
            format: date-time
            nullable: true
          closed_at:
            type: string
            format: date-time
            nullable: true
          anonymous:
            type: boolean
            description: Filed anonymously — `requester.id` / `email` are then null
              and `requester.name` is "Anonymous".
          urgency_reason:
            type: string
            nullable: true
          custom_fields:
            type: array
            description: Answered custom fields in the admin's layout order, blank
              ones omitted. Internal-only fields are included for Service Desk staff
              only.
            items:
              type: object
              properties:
                key:
                  type: string
                  description: The field's `field_name`
                label:
                  type: string
                value:
                  type: string
                  description: Display text ("Yes"/"No" for a checkbox, a formatted
                    date, a joined multiselect)
          comments_count:
            type: integer
          comments:
            type: array
            items:
              "$ref": "#/components/schemas/TicketComment"
          permissions:
            "$ref": "#/components/schemas/TicketPermissions"
          source:
            type: string
            nullable: true
            description: Where the ticket came from (web, mobile, email, api, …)
          service_department:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          support_team:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          share_url:
            type: string
            description: Link to the ticket on the web, for sharing
          followers:
            type: array
            description: People on the ticket. Empty when the caller may not see them;
              a requester sees only people added by hand.
            items:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
                avatar_url:
                  type: string
                  nullable: true
                follow_type:
                  type: string
          time_worked_minutes:
            type: integer
            nullable: true
            description: Total time logged on the ticket. Service Desk staff only;
              null for everyone else.
          estimated_cost:
            type: number
            nullable: true
            description: The web ticket page's Work & Cost "Estimated" figure. An
              internal field — shown to Service Desk staff, managers, the assignee
              and the approver (the same people as sla_due_at); null for everyone
              else, and null when the ticket has none.
          actual_cost:
            type: number
            nullable: true
            description: The Work & Cost "Actual" figure, as an agent entered it —
              parts issued from inventory are not added to it (see `parts.total_cost`).
              Same visibility as estimated_cost; null when the ticket has none.
          over_budget:
            type: boolean
            nullable: true
            description: True when both figures are set and actual_cost is above estimated_cost
              — the web page draws Actual in red with an over-budget warning. Null
              for a caller who is not shown the cost figures.
          parts:
            type: object
            nullable: true
            description: The web ticket page's Work & Cost › Parts & materials card.
              An object (its lists possibly empty) for Service Desk staff with the
              Procurement app on and open to them — the rule behind `permissions.can_manage_parts`
              — and null for everyone else, so a requester never sees what the job
              cost internally. Show the card whenever this is not null; offer its
              Request parts action when `permissions.can_manage_parts` is true (false
              for a token without write:service_desk).
            properties:
              total_cost:
                type: number
                description: Net cost of every part issued to this job (returns subtract),
                  across all movements — not only the `items` window.
              truncated:
                type: boolean
                description: True when `items` holds only the 20 most recent movements.
              items:
                type: array
                description: Parts issued to or returned from this job, newest first.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    item:
                      type: object
                      nullable: true
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                    quantity:
                      type: integer
                    direction:
                      type: string
                      enum:
                      - issued
                      - returned
                    total_cost:
                      type: number
                      nullable: true
                    cost_center:
                      type: string
                      nullable: true
                    at:
                      type: string
                      format: date-time
              requests:
                type: array
                description: Parts asked of the warehouse that it still owes (`state`
                  open) or turned down and nobody has dismissed yet (`state` declined)
                  — what the web card lists above the issued history. A part only
                  requested is here, not in `items`.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    number:
                      type: string
                      description: The requisition number
                    state:
                      type: string
                      enum:
                      - open
                      - declined
                    status:
                      type: string
                      description: The requisition's own status
                    requested_for:
                      type: object
                      nullable: true
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                    collect_at:
                      type: object
                      nullable: true
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                    picking_by:
                      type: object
                      nullable: true
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                    decline_reason:
                      type: string
                      nullable: true
                    can_cancel:
                      type: boolean
                      description: DELETE /service_desk/{id}/parts/requests/{requisition_id}
                        will accept it (the warehouse has not started picking, and
                        the token holds destructive:service_desk).
                    lines:
                      type: array
                      items:
                        type: object
                        properties:
                          item:
                            type: object
                            nullable: true
                            properties:
                              id:
                                type: integer
                              name:
                                type: string
                          name:
                            type: string
                          quantity:
                            type: integer
                          issued:
                            type: integer
                          declined:
                            type: integer
                          remaining:
                            type: integer
                          estimated_cost:
                            type: number
                            description: Cost of what is still owed
                    requested_at:
                      type: string
                      format: date-time
              collect_points:
                type: array
                description: What POST /service_desk/{id}/parts accepts as `collect_at_location_id`.
                  Empty when the tenant has none, and the field is not asked.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
              last_collect_point_id:
                type: integer
                nullable: true
                description: The collection point this person chose last, to preselect.
              recipients:
                type: array
                description: What POST /service_desk/{id}/parts accepts as `requested_for_id`
                  — the caller and Service Desk staff on the job.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    me:
                      type: boolean
              project:
                description: The project this ticket bills to; null when it is not
                  billed to one.
                nullable: true
                allOf:
                - "$ref": "#/components/schemas/TicketBillingProject"
              billable_projects:
                type: array
                description: What PATCH /api/v1/service_desk/{id}/project accepts
                  — the tenant's active projects in its Procurement currency, plus
                  the ticket's own project.
                items:
                  "$ref": "#/components/schemas/TicketBillingProject"
              suggested_project:
                description: While no project is set, the one the ticket's location
                  points to; null when there is none or it is ambiguous.
                nullable: true
                allOf:
                - "$ref": "#/components/schemas/TicketBillingProject"
          pending_approver:
            type: object
            nullable: true
            description: While the ticket awaits approval, the person asked to decide
              it (the one the approval request went to).
            properties:
              id:
                type: integer
              name:
                type: string
          attachments:
            type: array
            description: Files attached to the ticket itself. Files on a comment are
              on that comment.
            items:
              "$ref": "#/components/schemas/ServiceDeskAttachment"
    TicketComment:
      type: object
      properties:
        id:
          type: integer
        content:
          type: string
        author:
          type: object
          properties:
            id:
              type: integer
              nullable: true
              description: Null for a system-posted comment and for the anonymous
                requester's own replies
            name:
              type: string
        internal:
          type: boolean
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/ServiceDeskAttachment"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ServiceCatalogType:
      type: object
      properties:
        type:
          type: string
          description: Slug identifier for the request type
        title:
          type: string
        icon:
          type: string
          description: Font Awesome icon class
        color:
          type: string
          description: Bootstrap color name
        description:
          type: string
        examples:
          type: string
        estimated_resolution:
          type: string
        sla_hours:
          type: integer
        custom:
          type: boolean
        category:
          type: string
        priority:
          type: string
          nullable: true
          description: The type's default priority; null when the type sets none
        service_department_id:
          type: integer
          nullable: true
        form:
          "$ref": "#/components/schemas/ServiceDeskIntakeTypeRules"
    KbSearchResult:
      type: object
      properties:
        id:
          oneOf:
          - type: integer
          - type: string
          description: |
            An integer for a `help_article` result — open it with
            `GET /service_desk/kb/articles/{article_id}`. Other sources carry a
            prefixed string id (e.g. `kb-entry-13` for a `kb_entry`) and are
            opened through `url`.
          example: 558
        title:
          type: string
        snippet:
          type: string
          description: Content excerpt (up to 500 characters)
        url:
          type: string
          description: Absolute URL to the article
        category:
          type: string
          nullable: true
        relevance_score:
          type: number
          format: float
          nullable: true
          description: Normalized relevance in [0, 1]; null when browsing
        source:
          type: string
          description: Where the result came from
          example: help_article
        status:
          type: string
          description: |
            `help_article` results only. The article's workflow status — an open
            set of strings (draft, pending_review, approved, published, rejected,
            archived today). This list returns published articles only.
          example: published
        status_label:
          type: string
          description: '`help_article` results only. The status as a person reads
            it ("Published").'
          example: Published
        view_count:
          type: integer
          description: "`help_article` results only."
        ratings:
          type: object
          description: |
            `help_article` results only. "Was this helpful?" votes; the web's
            Helpfulness is helpful / (helpful + not_helpful).
          properties:
            helpful:
              type: integer
            not_helpful:
              type: integer
    AskAiMessageResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        context_app:
          type: object
          nullable: true
          description: |
            Present only when the request sent `app_slug`. The app the turn is
            bound to, or null when the slug was not honoured (unknown, not open
            to this person, or no agent for it). Show an "Asking about …"
            indicator only when this is non-null.
          properties:
            slug:
              type: string
              example: tasks
            name:
              type: string
              example: Tasks
        conversation_id:
          type: string
          format: uuid
          description: Unique identifier for this conversation
          example: 550e8400-e29b-41d4-a716-446655440000
        status:
          type: string
          enum:
          - processing
          - completed
          - error
          example: processing
        websocket:
          type: object
          description: WebSocket subscription details
          properties:
            channel:
              type: string
              example: AiResponseChannel
            subscription:
              type: object
              properties:
                conversation_id:
                  type: string
                  format: uuid
    AskAiConversation:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Conversation session ID
        type:
          type: string
          enum:
          - forms
          - tasks
          - epms
          - general
          example: general
        status:
          type: string
          enum:
          - active
          - completed
          - archived
          example: active
        message_count:
          type: integer
          example: 12
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
    AskAiMessage:
      type: object
      properties:
        role:
          type: string
          enum:
          - user
          - assistant
          - system
          example: assistant
        content:
          type: string
          example: You have 15 PTO days remaining for this year.
          description: |
            The message text. An answer grounded in company knowledge lists its
            sources only in `metadata.provenance.sources` (or, for rows that nest
            it, `metadata.metadata.provenance.sources`) — its text carries no
            "Sources" list, so render that list.
        timestamp:
          type: string
          format: date-time
        ai_generated:
          type: boolean
          example: true
          description: |
            Machine-readable AI-output mark (EU AI Act Art. 50(2)): true on a
            stored assistant answer, false on the person's own words. The web
            transcript's `data-ai-generated` attribute and the live `complete`
            frame say the same.
        metadata:
          type: object
          additionalProperties: true
          description: |
            Allowlisted message metadata (see the Ask AI guide, "Message
            metadata"). The row's own keys may sit one level down under
            `metadata`; read each key at its current path.
          properties:
            provenance:
              "$ref": "#/components/schemas/AnswerProvenance"
            turn_id:
              type: string
              description: Exact answer identity for feedback and correction requests.
            feedback_allowed:
              type: boolean
              description: False when the caller cannot submit feedback, including
                read-only tokens and tool-confirmation outcome rows; hide rating controls
                when false.
            correctable:
              type: boolean
              description: False when this answer cannot accept a knowledge correction.
            answer_feedback:
              type: object
              description: The caller's saved feedback. Staff review fields are never
                included.
              properties:
                rating:
                  type: string
                  enum:
                  - up
                  - down
                reason:
                  type: string
                  enum:
                  - incorrect
                  - missing
                  - off_topic
                  - technical
                comment:
                  type: string
                  maxLength: 2000
            escalated_label:
              type: string
              description: |
                Present on an answer already escalated with "Get human help":
                where it went, in the web's own words ("Sent to support (#123)
                on Oct 12"), in the tenant's language. An escalated answer
                carries no `escalation_offer` — do not offer the button again.
              example: Sent to support (#123) on Oct 12
            escalated_ticket_url:
              type: string
              format: uri
              description: |
                The escalation's Service Desk ticket page (absolute URL), for
                linking `escalated_label` as the web and phone do. Present only
                when the answer went to a ticket and Service Desk opens for
                this person.
              example: https://acme.workforce.mangoapps.com/apps/service-desk/123
    AskAiAgentInfo:
      type: object
      properties:
        name:
          type: string
          example: Scheduling Assistant
        slug:
          type: string
          example: scheduling
        description:
          type: string
          example: Helps with shifts, schedules, and time off requests
        icon:
          type: string
          example: calendar
        examples:
          type: array
          items:
            type: string
          example:
          - What shifts do I have this week?
          - Request time off for next Friday
          - Who is working tomorrow?
    VoiceTokenResponse:
      type: object
      description: Response from voice_token endpoint with all connection details
        for voice mode
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          properties:
            token:
              type: string
              description: Ephemeral token for OpenAI Realtime API (expires in 60
                seconds)
              example: eph_abc123...
            session_id:
              type: string
              format: uuid
              description: Unique session ID for this voice session
              example: 550e8400-e29b-41d4-a716-446655440000
            cable_token:
              type: string
              nullable: true
              description: |
                Short-lived ActionCable credential scoped to `VoiceRealtimeChannel` for
                THIS `session_id` only. Connect with `wss://<host>/cable?cable_token=…`
                and subscribe `{ channel: "VoiceRealtimeChannel", session_id }`; any other
                channel or session is rejected. `null` (the call is still 200) when the
                server could not store it — end the session and mint again. The same value
                is also at `connections.actioncable.cable_token` for older clients.
            cable_token_expires_in:
              type: integer
              nullable: true
              description: Seconds the cable token lives — the session cap (900 with
                the default 15-minute cap).
              example: 900
            expires_at:
              type: string
              format: date-time
              description: When the ephemeral token expires
              example: '2025-01-26T14:21:00Z'
            session_config:
              type: object
              description: Configuration for OpenAI Realtime session
              properties:
                voice:
                  type: string
                  example: alloy
                modalities:
                  type: array
                  items:
                    type: string
                  example:
                  - text
                  - audio
                turn_detection:
                  type: object
                  properties:
                    type:
                      type: string
                      example: server_vad
                    threshold:
                      type: number
                      example: 0.5
            limit_info:
              type: object
              description: Voice usage limits and current usage
              properties:
                daily_used:
                  type: integer
                  description: Minutes used today
                  example: 5
                daily_limit:
                  type: integer
                  description: Daily limit in minutes
                  example: 30
                remaining:
                  type: integer
                  description: Remaining minutes for today
                  example: 25
                session_limit:
                  type: integer
                  description: Max minutes per session
                  example: 15
                monthly_sessions:
                  type: integer
                  description: Total voice sessions this billing period
                monthly_minutes:
                  type: number
                  description: Total minutes used this billing period
                monthly_cost_cents:
                  type: integer
                  description: Total cost in cents this billing period
            billing_info:
              type: object
              description: Billing information for voice usage
              properties:
                balance:
                  type: integer
                  description: Available balance in cents
                  example: 1000
                cost_per_minute_cents:
                  type: integer
                  description: Cost per minute in cents
                  example: 10
            connections:
              type: object
              description: Connection details for WebSocket and OpenAI
              properties:
                actioncable:
                  type: object
                  description: ActionCable WebSocket connection details
                  properties:
                    url:
                      type: string
                      description: WebSocket URL for ActionCable
                      example: wss://mycompany.workforce.mangoapps.com/cable
                    channel:
                      type: string
                      description: Channel name to subscribe to
                      example: VoiceRealtimeChannel
                    params:
                      type: object
                      description: Parameters to include in subscription
                      properties:
                        session_id:
                          type: string
                          format: uuid
                openai_realtime:
                  type: object
                  description: OpenAI Realtime API connection details
                  properties:
                    url:
                      type: string
                      description: OpenAI Realtime API base URL
                      example: https://api.openai.com/v1/realtime
                    model:
                      type: string
                      description: OpenAI model for voice
                      example: gpt-4o-realtime-preview
                    sdp_endpoint:
                      type: string
                      description: Endpoint for SDP exchange
                      example: https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview
    VoiceSessionStatus:
      type: object
      description: Current status of a voice session
      properties:
        success:
          type: boolean
          example: true
        session:
          type: object
          properties:
            id:
              type: string
              format: uuid
              description: Session ID
              example: 550e8400-e29b-41d4-a716-446655440000
            status:
              type: string
              enum:
              - active
              - completed
              - pending
              description: Current session status
              example: active
            started_at:
              type: string
              format: date-time
              description: When the session started
            duration_seconds:
              type: integer
              description: Current or total session duration in seconds
              example: 120
            queries_processed:
              type: integer
              description: Number of voice queries processed in this session
              example: 5
    VoiceSessionEndResponse:
      type: object
      description: Response when ending a voice session
      properties:
        success:
          type: boolean
          example: true
        session:
          type: object
          properties:
            id:
              type: string
              format: uuid
              description: Session ID that was ended
              example: 550e8400-e29b-41d4-a716-446655440000
            duration_minutes:
              type: number
              description: Total session duration in minutes
              example: 5
            cost_cents:
              type: integer
              description: Total cost of the session in cents
              example: 50
            billed:
              type: boolean
              description: Whether billing has been processed
              example: true
    MessagingPaginationMeta:
      type: object
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    MessagingParticipant:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
        avatar_url:
          type: string
          nullable: true
    MessagingRecipient:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        text:
          type: string
        email:
          type: string
        meta:
          type: string
          description: Context line (title · employee id · email).
    MessagingAttachment:
      type: object
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        url:
          type: string
          nullable: true
          description: Absolute download URL.
        preview_url:
          type: string
          nullable: true
          description: Inline URL for raster images only.
        thumbnail_url:
          type: string
          nullable: true
    MessagingMessage:
      type: object
      properties:
        id:
          type: integer
        thread_id:
          type: integer
        author_id:
          type: integer
        author:
          "$ref": "#/components/schemas/MessagingParticipant"
        body:
          type: string
        mentioned_user_ids:
          type: array
          items:
            type: integer
        edited_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/MessagingAttachment"
        voicenotes:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              audio_url:
                type: string
                nullable: true
              transcript:
                type: string
                nullable: true
              created_at:
                type: string
                format: date-time
        reactions:
          "$ref": "#/components/schemas/MessagingReactions"
        link_preview:
          type: object
          nullable: true
          description: The first link's card, made on the server after the message
            is sent; null until then, when the page has nothing to show, or while
            the Messages "Link previews" setting is off.
          properties:
            url:
              type: string
            title:
              type: string
              nullable: true
            description:
              type: string
              nullable: true
            image_url:
              type: string
              nullable: true
              description: Our own copy of the page's image, on this host — never
                the linked site's address.
            site_name:
              type: string
              nullable: true
    MessagingThread:
      type: object
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - one_on_one
          - group
        title:
          type: string
        subject:
          type: string
          nullable: true
          description: 'The conversation''s subject line ("Fwd: …" after a forward);
            null when none was given.

            '
        one_on_one:
          type: boolean
        archived_at:
          type: string
          format: date-time
          nullable: true
        last_message_at:
          type: string
          format: date-time
          nullable: true
        unread_count:
          type: integer
        muted:
          type: boolean
          description: 'Whether the CALLER has this conversation muted (their own
            participant `muted_until` is in the future). Present on both the list
            and detail responses so a client can render Mute vs Unmute.

            '
        last_message_preview:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            author_id:
              type: integer
            body:
              type: string
              description: 'Excerpt of the newest message, ready to render as-is.
                An attachment-only message has a blank body, so this carries "Attachment"
                / "N attachments" instead of an empty string.

                '
            created_at:
              type: string
              format: date-time
            has_attachments:
              type: boolean
    MessagingThreadDetail:
      allOf:
      - "$ref": "#/components/schemas/MessagingThread"
      - type: object
        properties:
          participants:
            type: array
            items:
              "$ref": "#/components/schemas/MessagingParticipant"
          recent_changes:
            type: array
            description: 'A group''s own history, oldest first (the latest 20 inside
              the retention window): who renamed it, added people or left. Recorded
              from 2026-10-26; empty for a one-to-one conversation.'
            items:
              type: object
              properties:
                action:
                  type: string
                  enum:
                  - renamed
                  - added
                  - left
                at:
                  type: string
                  format: date-time
                by_user_id:
                  type: integer
                  nullable: true
                  description: Null when the account was erased.
                renamed_from:
                  type: string
                  nullable: true
                renamed_to:
                  type: string
                  nullable: true
                added_user_ids:
                  type: array
                  items:
                    type: integer
                text:
                  type: string
                  description: The sentence the web and phone conversation draw, in
                    the reader's language.
    ChatPaginationMeta:
      type: object
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
    ChatUserSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        avatar_url:
          type: string
          nullable: true
        avatar_updated_at:
          type: integer
          nullable: true
          description: Epoch seconds — cache-busting stamp.
    ChatRoom:
      type: object
      properties:
        id:
          type: integer
        room_type:
          type: string
          enum:
          - direct
          - group
          - channel
        name:
          type: string
          nullable: true
          description: 'The room''s own name: a channel''s, a named group''s. Null
            for a direct message and for a group from before names were required —
            draw `display_name`.'
        display_name:
          type: string
          description: 'The title to show, never null, in the caller''s language:
            `name` when set; a group with none by its people where the caller may
            see who is in it (`can_see_members`) — the first three still in it and
            how many more, "Ann, Bo, Cy and 2 more", as the web and phone lists name
            it; a direct message by the other person; else "Untitled group", "Untitled
            channel" or "Direct message".'
        topic:
          type: string
          nullable: true
        avatar:
          allOf:
          - "$ref": "#/components/schemas/ChatChannelAvatar"
          nullable: true
          description: 'A channel''s own picture when it has no photo (`image_url`):
            one emoji on one colour. Null for a group or a direct message, and for
            a channel whose picture has not been picked yet — draw your own # / lock
            then.'
        created_by_id:
          type: integer
          nullable: true
          description: Who created the room. The creator of a group or channel is
            offered no Leave (the web hides it too); on a note to self it is its owner.
            `is_room_admin` cannot stand in — a promoted admin is not the creator.
        is_note_to_self:
          type: boolean
          description: 'The caller''s own conversation: a direct room with no other
            person, so every `other_user_*` field is null. Listed only when rooms#index
            is asked `include_note_to_self`.'
        is_room_admin:
          type: boolean
          description: The caller is an admin of this room (the person's standing;
            whether this request may edit is `can_edit_room`).
        is_team_admin:
          type: boolean
          description: Same value as `is_room_admin` (the messenger's older name).
        can_edit_room:
          type: boolean
          description: 'This request may change the room (PATCH /chat/rooms/{id}):
            a channel when `can_edit_details`; otherwise a group the caller is an
            admin of, with a token holding `write:chat`. Never a direct message. Draw
            Save only when true — a read-only token gets 403.'
        can_see_members:
          type: boolean
          description: May see who is in the room (the room's `can_view_member` setting,
            or an admin). `member_count` is null when false.
        can_send_im:
          type: boolean
          description: May post here; false with a reason in `send_restriction`, and
            for a token without `write:chat`.
        can_add_members:
          type: boolean
          description: May add people (`other_member_invite`, or an admin); never
            in a direct message, an archived room or a member-synced channel.
        can_pin:
          type: boolean
          description: 'May pin a message: whoever may post, plus the room''s admins;
            never in an archived room.'
        can_broadcast_mentions:
          type: boolean
          description: 'May use @channel / @here / @group: never in a direct message
            or where the caller cannot post; a room admin always; else whoever the
            company lets use them.'
        has_read_receipt_messages:
          type: boolean
          description: A read-receipt message here still waits for the caller's acknowledgement
            — the room stays under Unread until it is acknowledged, even once `unread_count`
            is 0.
        read_receipt_message_count:
          type: integer
          description: Read-receipt messages here the caller has not acknowledged.
        other_member_invite:
          type: string
          enum:
          - "Y"
          - "N"
          description: The room's stored setting — see ChatRoomPermissionCodes.
        can_view_member:
          type: string
          enum:
          - B
          - G
          description: The room's stored setting — see ChatRoomPermissionCodes.
        im_permission:
          type: string
          enum:
          - B
          - G
          - S
          description: The room's stored setting — see ChatRoomPermissionCodes.
        important_msg_sender:
          type: string
          enum:
          - B
          - G
          - S
          description: The room's stored setting — see ChatRoomPermissionCodes.
        created_at:
          type: string
          format: date-time
          description: 'When the conversation began. Where its messages expire (`retention_days`)
            and it is older than that many days, the oldest message shown may not
            be its first: the web and phone history then start with "Older messages
            are deleted after 90 days (your company''s retention setting)." instead
            of "This is the beginning of the conversation."'
        is_public:
          type: boolean
          description: A channel listed in Browse Channels and open to join; false
            for every DM and group.
        archived:
          type: boolean
          description: An archived channel is read-only (D11).
        last_message_at:
          type: string
          format: date-time
          nullable: true
        archived_at:
          type: string
          format: date-time
          nullable: true
        member_count:
          type: integer
        unread_count:
          type: integer
        last_read_message_id:
          type: integer
          nullable: true
        pinned_at:
          type: string
          format: date-time
          nullable: true
        is_muted:
          type: boolean
        mute_end_time:
          type: string
          format: date-time
          nullable: true
        other_user_id:
          type: integer
          nullable: true
          description: DM peer (direct rooms only).
        other_user_name:
          type: string
          nullable: true
        other_user_avatar_url:
          type: string
          nullable: true
        other_user_avatar_updated_at:
          type: integer
          nullable: true
        last_message_preview:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            body:
              type: string
              description: 'Excerpt of the newest message, ready to render as-is.
                An attachment-only message has a blank body, so this carries a description
                of the attachment instead ("Photo", "Video", "Attachment: report.pdf",
                "3 attachments") — never an empty string. Author-less: prefix with
                `user_name` yourself.

                '
            user_id:
              type: integer
            user_name:
              type: string
            created_at:
              type: string
              format: date-time
            has_attachments:
              type: boolean
        has_important_messages:
          type: boolean
        important_message_count:
          type: integer
        has_self_mention:
          type: boolean
          description: 'The caller has an unread message here that @mentions them
            — by name, or an @channel / @here / group mention that reached them —
            by the rule the Mentions view marks "New" with: past their read position
            (for a reply only its thread shows, past their position in that thread),
            in a room they are still in, in a message not deleted. False for someone
            not in the room, and in an archived room (as on the Home page''s Chat
            card).'
        unread_mention_count:
          type: integer
          description: How many such messages (0 when has_self_mention is false).
        can_send_important_msg:
          type: boolean
        can_request_read_receipt:
          type: boolean
          description: May ask for a read receipt on a new message here.
        ack_restriction:
          type: object
          nullable: true
          description: 'Why `can_send_important_msg` is false — show the Important
            control disabled with `message`. The first that applies, in the order
            a send is refused: posting itself (the same code and sentence as `send_restriction`,
            including `read_only_token`), then `ack_not_allowed_in_channel` (Important
            messages and read receipts are for direct messages, groups and private
            channels of up to 50 people — D12), then `important_not_allowed` (the
            company''s Important messages switch is off, or this room''s "Who can
            send important messages" tier leaves the caller out). Null while the caller
            may send one, and for someone not in the room.'
          properties:
            code:
              type: string
              enum:
              - ack_not_allowed_in_channel
              - important_not_allowed
              - information_barrier
              - im_not_allowed
              - read_only_token
            message:
              type: string
              example: Important messages and read receipts are for private channels
                of up to 50 people.
        read_receipt_restriction:
          type: object
          nullable: true
          description: 'Why `can_request_read_receipt` is false, the same way: posting
            itself, then `ack_not_allowed_in_channel`, then `read_receipts_not_allowed`
            (the company''s read-receipt switch is off) or `read_receipts_not_allowed_here`
            (this room''s tier leaves the caller out). The two switches are separate,
            so this can differ from `ack_restriction`. Null while the caller may ask,
            and for someone not in the room.'
          properties:
            code:
              type: string
              enum:
              - ack_not_allowed_in_channel
              - read_receipts_not_allowed
              - read_receipts_not_allowed_here
              - information_barrier
              - im_not_allowed
              - read_only_token
            message:
              type: string
              example: Read-receipt requests are not enabled for your organization.
        can_delete_self_message:
          type: boolean
        can_delete_other_message:
          type: boolean
        can_reply_in_thread:
          type: boolean
          description: 'Channels only: may reply in a thread (D8).'
        can_archive:
          type: boolean
          description: May archive / restore this channel (D11; needs destructive:chat).
        can_make_private:
          type: boolean
          description: May make this public channel private (D9).
        can_make_public:
          type: boolean
          description: May make this private channel public — a company Chat admin
            only (D9).
        can_edit_details:
          type: boolean
          description: May change this channel's name and topic.
        can_delete_for_everyone:
          type: boolean
          description: May delete it for everyone (needs destructive:chat).
        can_close_polls:
          type: boolean
          description: May close anyone's poll here — a room admin, not in an archived
            channel (a poll's author may always close their own; needs destructive:chat).
        thread_replies:
          type: string
          enum:
          - B
          - G
          description: 'Who may reply in threads (channels): B any member, G admins
            only.'
        ask_ai_proactive_enabled:
          type: boolean
          description: The room is opted in to Ask AI's unprompted suggestions (set
            with PATCH /chat/rooms/{id}).
        ask_ai_proactive_detectors:
          type: object
          description: Which suggestions it makes when opted in.
          properties:
            unanswered_question:
              type: boolean
            meeting_intent:
              type: boolean
            commitment:
              type: boolean
        image_url:
          type: string
          nullable: true
          description: Group/channel photo.
        image_updated_at:
          type: integer
          nullable: true
        service_account_kind:
          type: string
          nullable: true
          enum:
          - approvals
          - notifications
          -
          description: 'A direct message with one of Chat''s service accounts: `approvals`
            (the Needs you conversation — what is waiting on the caller, as cards
            they can answer from: approvals, acknowledgements and sign-offs) or `notifications`
            (copies of the caller''s bell notices, when they asked for them). Read-only
            for the caller — `can_send_im` is false with the reason in `send_restriction`;
            draw it as the app''s own conversation, not a person. Null for every other
            room.'
        notify_level:
          type: string
          nullable: true
          enum:
          - all
          - mentions
          - none
          -
          description: The caller's notification level in force (a channel of more
            than 50 members defaults to mentions). Null when not a member.
        notify_level_setting:
          type: string
          nullable: true
          enum:
          - all
          - mentions
          - none
          -
          description: What the caller chose; null follows the room's default.
        default_notify_level:
          type: string
          nullable: true
          enum:
          - all
          - mentions
          -
          description: The level the caller has here with no setting of their own
            — mentions for a channel of more than 50 members, else all; the same answer
            PATCH /chat/rooms/{id}/notifications gives. Null when not a member.
        walkie_talkie:
          type: boolean
          description: 'The caller''s walkie-talkie switch here (PATCH /chat/rooms/{id}/walkie_talkie):
            on, push-to-talk clips play as they arrive. False when not a member.'
        draft_preview:
          type: string
          nullable: true
          description: 'The caller''s unsent draft here, as a list shows it ("Draft:
            …"); null without one.'
        org_source:
          type: string
          nullable: true
          enum:
          - group
          - location
          - team
          - shift
          -
          description: 'What made this channel: a group an admin picked, a location,
            a manager''s team, or a location''s who''s-on-shift-today channel. Null
            for one made by hand.'
        synced_from:
          type: object
          nullable: true
          description: While the members are synced from that source (people join
            and leave with it; Add people is refused, and so are Leave and Remove
            for anyone the source holds — 422 `org_synced`; `can_add_members` is false,
            and `can_leave` is false for someone the source holds). Null otherwise,
            including after the setting that made it is turned off, after a location
            left the Location channels level, and after Stop syncing (DELETE /chat/rooms/{id}/org_sync).
          properties:
            type:
              type: string
              enum:
              - group
              - location
              - team
              - shift
            id:
              type: integer
              description: The group's id for a group; the location's or the manager's
                for the others.
            name:
              type: string
              description: Where the members come from, as a sentence fragment ("Store
                412", "Dana Lee's team").
            source_empty_since:
              type: string
              format: date-time
              nullable: true
              description: 'When the group it follows was found holding nobody: the
                channel kept its members since, and people join and leave again once
                the group holds people. Null otherwise (always null for a location,
                team or shift).'
        org_sync_stopped:
          type: boolean
          description: 'The group it followed was deleted: it keeps its members and
            no longer syncs.'
        can_leave:
          type: boolean
          description: May leave this room (false in a DM, and in a synced channel
            for someone its source holds — a former manager kept as admin, or someone
            added by hand, may leave; creator and last-admin refusals still apply).
            Needs destructive:chat.
        can_stop_org_sync:
          type: boolean
          description: 'May stop this channel syncing its members (DELETE /chat/rooms/{id}/org_sync):
            the channel''s admins and Chat admins, on a channel that follows a source
            and is not archived.'
        can_manage_webhooks:
          type: boolean
          description: 'May list, add and revoke this channel''s incoming webhooks
            (/chat/rooms/{id}/webhooks): the channel''s admins and Chat admins; on
            an archived channel, listing and revoking only. Needs write:chat.'
        send_restriction:
          type: object
          nullable: true
          description: 'Why `can_send_im` is false — the sentence the web and the
            phone show in place of the message box: the room''s own reason first,
            else the token''s. Null while the caller may post, and for someone not
            in the room.'
          properties:
            code:
              type: string
              enum:
              - information_barrier
              - im_not_allowed
              - read_only_token
              description: "`information_barrier` for a direct message the company's
                information barriers keep apart (sends, reactions, pins and edits
                are refused; deleting your own messages still works); `im_not_allowed`
                for every other reason the room refuses a post; `read_only_token`
                when the room would take the post but the caller's token has no `write:chat`."
            message:
              type: string
              example: Your company's policy doesn't allow messages between you and
                Dana Lee.
        on_shift_count:
          type: integer
          nullable: true
          description: Members on shift now (GET /chat/rooms/{id}/on_shift lists them).
            Null for a non-member and where the roster is kept to admins.
        retention_days:
          type: integer
          nullable: true
          description: 'Messages here are deleted after this many days (say so once,
            e.g. "Messages older than 90 days are deleted."): hidden from everyone,
            then permanently deleted 30 days later. Null when this kind of conversation
            keeps them forever. The same for every viewer, whether or not the room
            is on legal hold.'
        on_legal_hold:
          type: boolean
          nullable: true
          description: 'The room, one of its messages, or someone in it is on legal
            hold: nothing in it is permanently deleted (messages past the retention
            window are still hidden) and it can''t be deleted for everyone. Business
            (company) admins only; null for everyone else.'
        held_by_other:
          type: boolean
          nullable: true
          description: 'Held, with no hold on the room itself: a hold on someone in
            it, or on one of its messages, keeps it — lifted on Admin › Retention
            & Legal Holds, not from the conversation. The web''s "On legal hold" badge
            says which in its tooltip. False when nothing holds it or the hold is
            on the room; business (company) admins only, null for everyone else.'
        delete_restriction:
          type: object
          nullable: true
          description: Why delete for everyone is refused to someone who could otherwise
            delete it — the room is held. `code` is `legal_hold` for a business admin
            (the message names the hold and where to lift it) and `not_deletable`
            for anyone else (the message names no hold). Show the delete control disabled
            with `message`. Null otherwise, and for a token without destructive:chat.
          properties:
            code:
              type: string
              enum:
              - legal_hold
              - not_deletable
            message:
              type: string
        can_manage_legal_hold:
          type: boolean
          description: 'The conversation''s own menu offers placing or lifting its
            legal hold (POST / DELETE /chat/rooms/{id}/legal_hold): a business admin
            in it, under write:chat. A business admin who is not in it may hold it
            by its id too.'
    ChatRoomPermissionCodes:
      type: object
      description: Any of the five settings; a key left out is unchanged (or, on a
        new room, its default). Codes are upper-case, exactly as listed. "Admins"
        means room admins and business admins.
      properties:
        other_member_invite:
          type: string
          enum:
          - "Y"
          - "N"
          description: 'Who can add new members: Y any member, N admins only.'
        can_view_member:
          type: string
          enum:
          - B
          - G
          description: 'Who can see the member list: B any member, G admins only.'
        im_permission:
          type: string
          enum:
          - B
          - G
          - S
          description: 'Who can send messages: B any member, G admins only, S business
            admins only.'
        important_msg_sender:
          type: string
          enum:
          - B
          - G
          - S
          description: 'Who can send important messages and request read receipts:
            B any member, G admins only, S business admins only.'
        thread_replies:
          type: string
          enum:
          - B
          - G
          description: 'Who can reply in threads: B any member, G admins only. Read
            only in a channel.'
    ChatAttachment:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
          description: Short-lived signed download URL.
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        media_type:
          type: string
          enum:
          - image
          - gif
          - video
          - file
        processing_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        preview_url:
          type: string
          nullable: true
          description: Inline URL for raster images only.
        thumbnail_url:
          type: string
          nullable: true
        playback_url:
          type: string
          nullable: true
          description: Signed URL of the 720p H.264 MP4 playback rendition, when the
            video has one. Players should prefer it and fall back to the original
            (`storage_url` / `url`). Null while processing, and for uploads that already
            play everywhere.
        hls_playlist_url:
          type: string
          nullable: true
          deprecated: true
          description: No longer produced; present only on videos transcoded before
            2026-09.
        subtitle_url:
          type: string
          nullable: true
          description: Video with subtitles ready.
        duration_ms:
          type: integer
          nullable: true
          example: 7400
          description: A voice note's length in milliseconds, as its recorder measured
            it (the `duration_ms` sent with `POST …/voice_notes`) — show it instead
            of the audio element's own, which a WebM recording leaves at Infinity
            until it has played through. Null for every other attachment, and for
            voice notes sent without one (or before it was kept). The `message:created`
            payload's attachments carry it too.
    ChatReaction:
      type: object
      properties:
        id:
          type: integer
        emoji:
          type: string
        user_id:
          type: integer
    ChatMessage:
      type: object
      properties:
        id:
          type: integer
        room_id:
          type: integer
        user_id:
          type: integer
        user_name:
          type: string
          nullable: true
          description: The sender's display name; null when the sender's account is
            gone. Mirrors the `message:created` Pusher payload.
        user_avatar_url:
          type: string
          nullable: true
          description: The sender's avatar image URL; null when the sender has no
            photo or the account is gone.
        user_avatar_updated_at:
          type: integer
          nullable: true
          description: Epoch seconds the sender's avatar last changed, for cache-busting;
            null without a photo.
          example: 1761600000
        user_designation:
          type: string
          nullable: true
          description: The sender's job title as this business shows it — their membership's
            title (set by the profile's Job Title select), else the profile's own.
            Present only where the People directory shows job titles to the caller;
            the caller's own title follows that field's Visible-to level. Null when
            hidden or when the sender has no title.
        body:
          type: string
          description: Message text, ALREADY MASKED where the caller is not entitled
            to it. When `body_masked` is true this is a fixed notice string, not the
            sender's words — render it verbatim and never treat it as content to cache,
            quote, search or preview. The mask is identical on every surface that
            hands this message to a recipient (this response, the room-list `last_message_preview`,
            the push notification, and the per-user `message:created` Pusher event
            on `private-user-inbox-<uid>-business-<bid>`), so a client that renders
            one and refetches the other never sees the text change.
        body_masked:
          type: boolean
          description: 'True when `body` is the read-receipt mask rather than the
            message text. Viewer-scoped: a read-receipt request stays masked for a
            recipient until they POST `/chat/rooms/{room_id}/messages/{id}/acknowledge`,
            which is what notifies the sender and returns the revealed body. Never
            true for the author or for a message the caller has already acknowledged.'
        parent_message_id:
          type: integer
          nullable: true
        in_thread:
          type: boolean
          description: A reply in a channel — it belongs to its root's thread (D1).
            A client without threads shows it as the inline quote it always was.
        thread_only:
          type: boolean
          description: The reply was kept in its thread (not also sent to the channel
            timeline).
        thread:
          type: object
          description: With `threads=true`, on each root in a channel — its thread
            line.
          properties:
            reply_count:
              type: integer
            last_reply_at:
              type: string
              format: date-time
              nullable: true
            repliers:
              type: array
              items:
                "$ref": "#/components/schemas/ChatUserSummary"
            following:
              type: boolean
        parent:
          type: object
          nullable: true
          description: Quoted message excerpt (one-level inline quote).
          properties:
            id:
              type: integer
            body:
              type: string
              description: Excerpt (max 140 chars).
            user_id:
              type: integer
            user_name:
              type: string
            created_at:
              type: string
              format: date-time
            deleted:
              type: boolean
        created_at:
          type: string
          format: date-time
        edited_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
          description: Set on a deleted message's tombstone, which carries no body,
            attachments (`[]`), link_preview (null) or mention_groups (`[]`).
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/ChatAttachment"
        reactions:
          type: array
          items:
            "$ref": "#/components/schemas/ChatReaction"
        client_uuid:
          type: string
          nullable: true
        is_system:
          type: boolean
          description: 'A server notice ("Ann has added Bo to this chat.") — draw
            it as a centered line, not a bubble. Its `body` is already said for the
            caller: in their display language, and "You have …" / "… has added you
            …" where it names them. `system_kind` and `system_params` say what happened,
            for a client that says it itself.'
        system_kind:
          type: string
          nullable: true
          description: What a system notice records; null on a person's message and
            on a notice written before kinds were stored (its `body` is then the stored
            English). A client that does not know a kind shows `body`.
          enum:
          - chat_created
          - members_added
          - member_left
          - member_removed
          - admin_added
          - admin_removed
          - admin_succession
          - chat_renamed
          - channel_created
          - channel_members_added
          - channel_joined
          - channel_archived
          - channel_restored
          - channel_made_public
          - channel_made_private
          - channel_topic_set
          - channel_topic_cleared
          - group_converted
          - permission_changed
          - message_pinned
          - org_sync_stopped
          - org_detached
          - org_reattached
          - org_synced
          - org_channel_intro
          - huddle_recording
          - webhook_added
          - webhook_removed
          - webhook_renamed
          -
        system_params:
          type: object
          nullable: true
          description: The facts a system notice is said from — present keys depend
            on `system_kind`. People carry the name they had when it happened.
          properties:
            actor:
              type: object
              description: Who did it (absent when nobody did — admin_succession,
                org lines, an automatic group_converted).
              properties:
                id:
                  type: integer
                  nullable: true
                name:
                  type: string
            member:
              type: object
              description: The one person it is about (member_left, member_removed,
                admin_added, admin_removed, admin_succession).
              properties:
                id:
                  type: integer
                  nullable: true
                name:
                  type: string
            members:
              type: array
              description: The first five people it lists (members_added, channel_members_added).
              items:
                type: object
                properties:
                  id:
                    type: integer
                    nullable: true
                  name:
                    type: string
            member_ids:
              type: array
              description: Every listed person's id, up to 200 — to say "you" for
                a reader past the first five.
              items:
                type: integer
            member_count:
              type: integer
              description: How many people it lists in all ("and N others").
            from:
              type: string
              description: chat_renamed — the old name (absent when it had none).
            to:
              type: string
              description: chat_renamed — the new name.
            visibility:
              type: string
              enum:
              - public
              - private
              description: channel_created.
            topic:
              type: string
              description: channel_topic_set.
            setting:
              type: string
              description: permission_changed — the setting key (other_member_invite,
                can_view_member, im_permission, important_msg_sender, thread_replies).
            value:
              type: string
              description: permission_changed — its new value code (the codes of PATCH
                /chat/rooms/{id}/permissions).
            channel_name:
              type: string
              description: group_converted — the channel's name, without the '#'.
            source:
              type: object
              description: 'What a synced channel follows (org_* kinds): `kind` group,
                location, shift (who''s on shift at a location) or team (a manager''s),
                and its name.'
              properties:
                kind:
                  type: string
                name:
                  type: string
            added:
              type: integer
              description: org_synced — people who joined.
            removed:
              type: integer
              description: org_synced — people who left.
            webhook_name:
              type: string
              description: webhook_added, webhook_removed, webhook_renamed — the incoming
                webhook's name (POST /chat/rooms/{id}/webhooks); webhook_renamed's
                new one.
            previous_webhook_name:
              type: string
              description: webhook_renamed — the name it had before (PATCH /chat/rooms/{id}/webhooks/{webhook_id}).
        ack_type:
          type: string
          nullable: true
          enum:
          - important
          - read_receipt
          -
        is_important:
          type: boolean
        is_read_receipt:
          type: boolean
        requires_ack:
          type: boolean
        is_acked:
          type: boolean
          description: Whether the CALLER has acknowledged.
        msg_ack_at:
          type: string
          format: date-time
          nullable: true
        workflow_participants_count:
          type: integer
          description: Eligible recipients who acknowledged.
        ack_eligible_count:
          type: integer
          description: Total eligible recipients (excludes the author).
        link_preview:
          type: object
          nullable: true
          description: The first link's card; null when there is none, the sender
            removed it, or the body is masked. Arrives after the message, over `message:updated`
            with `link_preview_only`.
          properties:
            url:
              type: string
            title:
              type: string
              nullable: true
            description:
              type: string
              nullable: true
            image_url:
              type: string
              nullable: true
              description: https only.
            site_name:
              type: string
              nullable: true
            source:
              type: string
              nullable: true
              enum:
              - share
              description: '"share" on the card under a record shared from OneMango
                (a course, a page, a proud moment): draw its picture across the card,
                not as a thumbnail.'
        read_percent:
          type: integer
          nullable: true
          minimum: 0
          maximum: 100
          description: 'On the caller''s own message in a room of more than 50 members:
            the share of the other members who have read up to it (rounded down).
            Null elsewhere: on Important / read-receipt messages (they carry their
            own counts), on a reply kept in its thread, and while the company''s "Show
            who has seen a message" switch (seen_by_enabled) is off.'
        pinned:
          type: boolean
          description: Pinned to its conversation (POST …/pin).
        pinned_at:
          type: string
          format: date-time
          nullable: true
        pinned_by:
          nullable: true
          description: Who pinned it; null when not pinned.
          allOf:
          - "$ref": "#/components/schemas/ChatUserSummary"
        permalink:
          type: string
          description: 'The link to this message — absolute, on this company''s host:
            `/apps/chat/rooms/{room_id}?message={id}#message-{id}`. The web opens
            the conversation at it (its thread, for a reply kept in one); a phone
            is sent to the phone view of the same message.'
        saved:
          type: boolean
          description: The CALLER saved it for later (POST …/save).
        recorder_recording_id:
          type: integer
          nullable: true
          description: The Recorder recording a "Send to chat" card is for (open it
            with `GET /recorder/recordings/{id}`), so a client draws the card from
            the message instead of parsing its link. The same key the `message:created`
            push carries. Null on every other message, and withheld (null) with the
            body — on a deleted message, and until the reader acknowledges a read-receipt
            request.
          example: 4821
        recorder_kind:
          type: string
          nullable: true
          enum:
          - video
          - screenshot
          -
          description: Which card to draw for `recorder_recording_id`; null exactly
            when it is.
          example: video
        forwarded_from:
          nullable: true
          description: 'Set on a message posted by Forward (`POST …/messages/{id}/forward`):
            the message it quotes — the forwarded message itself when that was a forward
            too — and the forward''s own `body` cut into the note and the quoted words.
            The same for every reader, and the same object on `message:created`. Null
            on every other message, on a removed one, and on forwards sent before
            it existed (their `body` is the same plain text). A message with it cannot
            be edited (403 `forwarded_message`).'
          type: object
          properties:
            message_id:
              type: integer
              description: The message forwarded.
            room_id:
              type: integer
              description: The conversation it was said in.
            author:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
                  description: The name the body's "Forwarded from" line gives them.
            room_label:
              type: string
              description: 'Where it was said, never naming a private conversation:
                `#name` for a public channel, "a private channel", "a group", "a direct
                message", or "a conversation" when the room is gone. English; a reader
                who is in `room_id` can show that room''s own name.'
            room_kind:
              type: string
              nullable: true
              enum:
              - public_channel
              - private_channel
              - group
              - direct
              description: What kind of conversation `room_id` is, to word `room_label`
                in the reader's language; null when the room is gone.
            note:
              type: string
              description: The forwarder's note ("" when they wrote none) — the start
                of `body`.
            quoted_text:
              type: string
              description: 'The original''s words as `body` carries them: plain text,
                people as "@Name", broadcast words disarmed — without the "Forwarded
                from" line and the attachments line.'
            attachment_count:
              type: integer
              description: Attachments on the original (they stay with it).
            permalink:
              type: string
              description: The original's link on this company's host — `/apps/chat/rooms/{room_id}?message={message_id}#message-{message_id}`.
        escalation:
          nullable: true
          description: On the CALLER's own Important message with a backup — who is
            told if it isn't confirmed in time, and what happened. Null for everyone
            else. Changes arrive over `message:updated` with `update_kind "escalation"`
            (ids only — fetch the message again).
          allOf:
          - "$ref": "#/components/schemas/ChatMessageEscalation"
        card:
          nullable: true
          description: A card on a message from the Needs you service account (its
            DM with the caller) — an approval, or an Inbox ask to acknowledge or sign;
            null on every other message. Changes arrive over `message:updated` with
            `update_kind "card"`.
          allOf:
          - "$ref": "#/components/schemas/ChatMessageCard"
        transcript:
          type: object
          nullable: true
          description: A voice note's words — null on every other message (and while
            masked or deleted). Arrives later over `message:updated` with `update_kind
            "transcript"`.
          properties:
            status:
              type: string
              enum:
              - pending
              - completed
              - failed
            text:
              type: string
              nullable: true
            error:
              type: string
              nullable: true
              description: A sentence for the person when there is one (no speech
                heard
              the AI usage cap).:
        poll:
          nullable: true
          description: The poll on a poll message (its body is the question); null
            otherwise. Changes arrive over `message:updated` with `update_kind "poll"`.
          allOf:
          - "$ref": "#/components/schemas/ChatPoll"
        mention_groups:
          type: array
          description: The groups the body @mentions (`@[Name](group:id)`) by their
            real names — badge these; [] when none or masked. `@channel` and `@here`
            are plain words in the body.
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
        push_to_talk:
          type: boolean
          description: A voice note sent by holding the talk button (walkie-talkie).
            The message:created payload carries the same key; a listener whose room
            `walkie_talkie` is on plays it once.
    ChatMember:
      type: object
      properties:
        id:
          type: integer
          description: Membership id (use for member update/remove).
        user_id:
          type: integer
        role:
          type: string
          enum:
          - admin
          - member
        joined_at:
          type: string
          format: date-time
        muted_until:
          type: string
          format: date-time
          nullable: true
        walkie_talkie:
          type: boolean
          nullable: true
          description: The walkie-talkie switch — on the caller's own row only (a
            private listening preference, like muted_until); null on everyone else's.
        last_read_message_id:
          type: integer
          nullable: true
          description: |
            How far this member has read — what a client draws "Seen by" from
            (Chat Wave 4: in a group up to 50 as well as a 1:1 room). Null in a
            group or channel for a member a works council hides by name, and
            null for everyone but the caller while the company's "Show who has
            seen a message" switch (`chat_settings.seen_by_enabled`) is off —
            see `read_positions_hidden` on the list response.
        last_read_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When `last_read_message_id` last moved forward — the time of a 1:1
            room's "Seen by Ann at 3:15 PM", with its day named when it is not
            the message's own ("Seen by Ann today at …"), as the web and the
            phone draw it after a reload. Null exactly where
            `last_read_message_id` is, and also for a read from before the time
            was stored (Oct 2026) or a position moved back by Mark unread
            (the line then says "Seen by Ann").
        active_in_business:
          type: boolean
          description: |
            On the member list only. False for a person no longer active in this
            organization: they keep their membership row (history keeps its
            author), cannot be made an admin (`member_inactive`) and are not
            notified when removed.
        user:
          nullable: true
          allOf:
          - "$ref": "#/components/schemas/ChatUserSummary"
          - type: object
            properties:
              email:
                type: string
                nullable: true
                description: A coworker's email only where the People directory shows
                  the caller email; null otherwise. The caller's own row always carries
                  theirs.
    ChatMedia:
      type: object
      properties:
        id:
          type: integer
        media_type:
          type: string
          enum:
          - image
          - gif
          - video
          - file
        mime_type:
          type: string
        filename:
          type: string
        byte_size:
          type: integer
        processing_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        width_px:
          type: integer
          nullable: true
        height_px:
          type: integer
          nullable: true
        url:
          type: string
          nullable: true
        thumbnail_url:
          type: string
          nullable: true
        playback_url:
          type: string
          nullable: true
          description: Signed URL of the 720p H.264 MP4 playback rendition, when the
            video has one. Players should prefer it and fall back to the original
            (`storage_url` / `url`). Null while processing, and for uploads that already
            play everywhere.
        hls_playlist_url:
          type: string
          nullable: true
          deprecated: true
          description: No longer produced; present only on videos transcoded before
            2026-09.
    ChatSettings:
      type: object
      description: Business-level chat configuration (all keys optional on PATCH).
      properties:
        direct_messages_enabled:
          type: boolean
        group_chats_enabled:
          type: boolean
        allow_edit_chat_messages:
          type: boolean
        file_uploads_enabled:
          type: boolean
        max_attachment_size_mb:
          type: integer
          minimum: 1
          maximum: 500
        max_video_attachment_size_mb:
          type: integer
        typing_indicators:
          type: boolean
        seen_by_enabled:
          type: boolean
          description: '"Show who has seen a message" (default true). Off: no "Seen
            by" and no seen tick (only Sent / Delivered), no `message:read` events,
            no `read_percent`, and every other member''s `last_read_message_id` is
            null on the member list. Important / read-receipt acknowledgements are
            not affected. On: a client draws "Seen by" in a 1:1 room and in a group
            of up to 50 from the members'' `last_read_message_id` — the names of those
            who have read up to the message, up to three then "+N", on the sender''s
            newest own message that someone has read.'
        important_messages_enabled:
          type: boolean
        read_receipt_requests_enabled:
          type: boolean
        link_previews_enabled:
          type: boolean
          description: 'Chat Wave 2: a card for the first link in a message.'
        incoming_webhooks_enabled:
          type: boolean
          description: 'A channel''s admins may add incoming webhooks (GET/POST /chat/rooms/{id}/webhooks).
            Off: no webhook can be added and every webhook URL answers 403 action_prohibited;
            the webhooks are kept. Default true.'
        semantic_search_enabled:
          type: boolean
        chat_video_subtitles_enabled:
          type: boolean
        working_hours_start:
          type: integer
        working_hours_end:
          type: integer
        retention_days_direct:
          type: integer
          readOnly: true
          description: Days direct messages are kept (30–3650; a stored value under
            30 is applied as 30); 0 = forever. Not writable through PATCH /chat/settings.
        retention_days_group:
          type: integer
          readOnly: true
          description: Days group messages are kept (30–3650; a stored value under
            30 is applied as 30); 0 = forever. Not writable through PATCH /chat/settings.
        retention_days_channel:
          type: integer
          readOnly: true
          description: Days channel messages are kept (30–3650; a stored value under
            30 is applied as 30); 0 = forever. Not writable through PATCH /chat/settings.
    ChatPoll:
      type: object
      properties:
        id:
          type: integer
        question:
          type: string
        options:
          type: array
          items:
            type: object
            properties:
              text:
                type: string
              votes:
                type: integer
        total_votes:
          type: integer
          description: Votes cast (a multiple-choice voter may cast several).
        voter_count:
          type: integer
          description: People who voted.
        my_votes:
          type: array
          items:
            type: integer
          description: The caller's chosen option indexes ([] on a broadcast, which
            has no viewer).
        closed:
          type: boolean
        allow_multiple:
          type: boolean
        author_id:
          type: integer
          nullable: true
    ChatDraft:
      type: object
      properties:
        body:
          type: string
          nullable: true
          description: The encoded text a send would post; null with no draft.
        mention_user_ids:
          type: array
          items:
            type: integer
        updated_at:
          type: string
          format: date-time
          nullable: true
        parent_message_id:
          type: integer
          description: 'Only on a thread reply draft (`parent_message_id` asked):
            the root message of the thread it belongs to.'
    ChatScheduledMessage:
      type: object
      properties:
        id:
          type: integer
        room_id:
          type: integer
        body:
          type: string
        send_at:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - scheduled
          - sending
          - sent
          - failed
          - cancelled
        error:
          type: string
          nullable: true
          description: Why it was not sent (status failed).
        parent_message_id:
          type: integer
          nullable: true
        thread_only:
          type: boolean
        sent_message_id:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ChatOwnStatus:
      type: object
      properties:
        emoji:
          type: string
          nullable: true
        text:
          type: string
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: When the status clears; null = it does not.
        dnd:
          type: boolean
          description: Notifications paused.
        dnd_until:
          type: string
          format: date-time
          nullable: true
        out_of_office:
          type: boolean
          description: On approved leave today.
    ChatPusherConfig:
      type: object
      properties:
        key:
          type: string
          nullable: true
        cluster:
          type: string
        enabled:
          type: boolean
        user_id:
          type: integer
        business_id:
          type: integer
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Machine readable error code
            message:
              type: string
              description: Human readable error message
            details:
              type: object
              description: Additional error context
      required:
      - error
    ValidationErrors:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
                description: Field name with validation error
              message:
                type: string
                description: Validation error message
              code:
                type: string
                description: Always `validation_error`
              field_name:
                type: string
                description: |
                  Present only when an error on `base` is about one named field — a
                  survey or form question left blank (`"<label> is required"`). The
                  question's key, as sent in `submission_data`.
    ShiftWriteRequest:
      type: object
      required:
      - shift
      properties:
        shift:
          type: object
          description: |
            The shift's fields. On create, `name`, `start_time` and `end_time` are required. `location_id`
            is refused on create (400) and ignored on update; `needs_coverage` is computed, never sent.
            An explicit `"urgent": null` is ignored (the flag is left as it was).
          properties:
            name:
              type: string
            description:
              type: string
              nullable: true
            start_time:
              type: string
              format: date-time
            end_time:
              type: string
              format: date-time
            status:
              type: string
              description: Defaults to the shift's initial status when omitted
            location_department_id:
              type: integer
              nullable: true
              description: A department of the shift's location
            required_staff_count:
              type: integer
            urgent:
              type: boolean
            shift_label_id:
              type: integer
              nullable: true
            notes:
              type: string
              nullable: true
            planned_breaks_attributes:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    description: An existing planned break to change (omit to add
                      one)
                  break_type_id:
                    type: integer
                  timing_type:
                    type: string
                  scheduled_time:
                    type: string
                  window_start:
                    type: string
                  window_end:
                    type: string
                  timing_preferences:
                    type: object
                  is_required:
                    type: boolean
                  _destroy:
                    type: boolean
        user_ids:
          type: array
          items:
            type: integer
          description: |
            People to assign (members of this organization). Omit to leave the roster alone; on update,
            an empty array clears it.
    ShiftWriteResponse:
      type: object
      required:
      - id
      - name
      - start_time
      - end_time
      - status
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        status:
          type: string
        location_id:
          type: integer
          nullable: true
        location_department_id:
          type: integer
          nullable: true
        schedule_id:
          type: integer
          nullable: true
        scheduling_group_id:
          type: integer
          nullable: true
        scheduling_sub_group_id:
          type: integer
          nullable: true
        shift_type:
          type: string
          nullable: true
        required_staff_count:
          type: integer
          nullable: true
        needs_coverage:
          type: boolean
        urgent:
          type: boolean
        ends_next_day:
          type: boolean
          nullable: true
        is_open_shift:
          type: boolean
          nullable: true
        is_ad_hoc:
          type: boolean
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        users:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              first_name:
                type: string
              last_name:
                type: string
              name:
                type: string
              job_title:
                type: string
                nullable: true
        planned_breaks:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              break_type_id:
                type: integer
              timing_type:
                type: string
              scheduled_time:
                type: string
                nullable: true
              window_start:
                type: string
                nullable: true
              window_end:
                type: string
                nullable: true
              duration_minutes:
                type: integer
                nullable: true
              is_required:
                type: boolean
    ShiftBulkRow:
      type: object
      description: One row of POST /shifts/bulk_create or PUT /shifts/bulk_update
        (unwrapped — no `shift` key).
      properties:
        name:
          type: string
        description:
          type: string
          nullable: true
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        status:
          type: string
        location_id:
          type: integer
        location_department_id:
          type: integer
          nullable: true
        schedule_id:
          type: integer
          nullable: true
        required_staff_count:
          type: integer
        urgent:
          type: boolean
        ends_next_day:
          type: boolean
        is_open_shift:
          type: boolean
        notes:
          type: string
          nullable: true
    ShiftBulkResult:
      type: object
      properties:
        message:
          type: string
          example: Some shifts failed to create
        results:
          type: object
          properties:
            success:
              type: array
              description: IDs of the shifts that were written
              items:
                type: integer
            failed:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    description: bulk_update only
                  params:
                    type: object
                    description: bulk_create only — the row as received
                  errors:
                    type: array
                    items:
                      type: string
                  code:
                    type: string
                    description: "`externally_managed` when the shift is managed in
                      the workforce management system"
    ChangelogEntry:
      type: object
      properties:
        id:
          type: integer
          example: 1
        version:
          type: string
          example: 1.6.2
        title:
          type: string
          example: Improved Agent tools and performance
        description:
          type: string
          nullable: true
          example: Major improvements to AI Agent experience
        category:
          type: string
          enum:
          - general
          - feature
          - improvement
          - fix
          - patch
          example: feature
        priority:
          type: integer
          enum:
          - 0
          - 1
          - 2
          example: 1
          description: 0=Normal, 1=Important, 2=Critical
        published:
          type: boolean
          example: true
        release_date:
          type: string
          format: date
          example: '2025-09-11'
        created_at:
          type: string
          format: date-time
          example: '2025-09-16T22:34:26Z'
        updated_at:
          type: string
          format: date-time
          example: '2025-09-16T22:34:26Z'
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: System Admin
        public_url:
          type: string
          nullable: true
          example: https://workforce.mangoapps.com/changelog/1
    ChangelogEntryInput:
      type: object
      required:
      - version
      - title
      - content
      - release_date
      properties:
        version:
          type: string
          example: '1.7'
          description: Main version number (e.g., 1.6, 2.0)
        patch_version:
          type: string
          nullable: true
          example: '1'
          description: Patch number for minor releases (optional)
        title:
          type: string
          example: Enhanced AI capabilities and performance improvements
          description: Title of the changelog entry
        description:
          type: string
          nullable: true
          example: Next major release with improved AI models
          description: Brief description of the release (optional)
        content:
          type: string
          example: |
            ### New Features

            * Support for GPT-5 models
            * Improved code generation accuracy

            ### Performance Improvements

            * 50% faster response times
            * Reduced memory usage
          description: Markdown content of the changelog entry
        category:
          type: string
          enum:
          - general
          - feature
          - improvement
          - fix
          - patch
          default: general
          example: feature
        priority:
          type: integer
          enum:
          - 0
          - 1
          - 2
          default: 0
          example: 1
          description: 0=Normal, 1=Important, 2=Critical
        published:
          type: boolean
          default: false
          example: true
          description: Whether the entry should be published immediately
        release_date:
          type: string
          format: date
          example: '2025-10-01'
          description: Release date of the version
        metadata:
          type: object
          nullable: true
          example: {}
          description: Additional metadata (optional)
    ChangelogEntryResponse:
      type: object
      properties:
        id:
          type: integer
          example: 1
        version:
          type: string
          example: 1.7.1
        title:
          type: string
          example: Enhanced AI capabilities and performance improvements
        published:
          type: boolean
          example: true
        release_date:
          type: string
          format: date
          example: '2025-10-01'
        created_at:
          type: string
          format: date-time
          example: '2025-09-16T22:34:26Z'
        public_url:
          type: string
          nullable: true
          example: https://workforce.mangoapps.com/changelog/1
    RecognitionPerson:
      type: object
      description: 'A person as every Recognitions list names them. Deliberately identity-only
        — these payloads go to every employee, and the cards they mirror show a name
        and a face, nothing else. An automated lifecycle award reports a null `id`
        with the name `System (Automated)` rather than leaking the system principal.

        '
      required:
      - id
      - name
      properties:
        id:
          type: integer
          nullable: true
          example: 412
        name:
          type: string
          example: Patrick Smith
        title:
          type: string
          nullable: true
          description: Job title, when the person has one on file.
          example: Store Manager
        image:
          type: string
          nullable: true
          description: Absolute avatar URL (native clients can't resolve a relative
            path), or null when it can't be resolved.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/ey.../a.jpg
    WikiDashboardRow:
      type: object
      description: 'One wiki row in a Wikis dashboard list. Matches the fields the
        web dashboard row renders: `icon` always carries a usable FontAwesome class
        (falling back to `fas fa-book`) and `color` is whitelist-coerced to a hex
        value, so clients never receive an unusable icon or colour.

        '
      required:
      - id
      - title
      - icon
      - color
      properties:
        id:
          type: integer
          example: 17
        title:
          type: string
          example: Shift Swap Guidelines
        icon:
          type: string
          description: FontAwesome class; defaults to `fas fa-book`.
          example: fas fa-book-open
        color:
          type: string
          description: Hex colour (whitelist-coerced; falls back to `#6c757d`).
          example: "#3b7ddd"
    WikiCard:
      type: object
      description: 'One wiki''s display fields for the Wikis list. `icon` falls back
        to `fas fa-book` and `color` is whitelist-coerced to a safe hex, so clients
        never receive an unusable value. `bookmarked` is the calling user''s bookmark
        state for this wiki, resolved from the same store as the `?filter=bookmarked`
        scope — so a list screen can draw a filled or unfilled bookmark per row without
        a detail call per card.

        '
      required:
      - id
      - title
      - icon
      - color
      - bookmarked
      properties:
        id:
          type: integer
          example: 42
        title:
          type: string
          example: Employee Handbook
        icon:
          type: string
          example: fas fa-book
        color:
          type: string
          example: "#2E7D32"
        bookmarked:
          type: boolean
          description: Whether the calling user has bookmarked this wiki.
          example: true
        pinned:
          type: boolean
          deprecated: true
          description: DEPRECATED mirror of `bookmarked`, kept for native builds shipped
            before the pin→bookmark rename. Computed from the same set, so the two
            can never disagree. New integrations read `bookmarked`.
          example: true
    WikiSearchHit:
      description: 'One wiki matched by GET /wikis/search — the list card fields plus
        the wiki''s description, its creator (id + name) and its tags. `description`
        is null when the wiki has none (it is one of the columns the search matches
        on). `creator_id` is the wiki''s author; `creator_name` is null when that
        user no longer exists. `tags` is an empty array when untagged.

        '
      allOf:
      - "$ref": "#/components/schemas/WikiCard"
      - type: object
        required:
        - description
        - creator_id
        - creator_name
        - tags
        properties:
          description:
            type: string
            nullable: true
            description: The wiki's short description (one of the searched columns).
            example: Runbook for the deploy pipeline
          creator_id:
            type: integer
            nullable: true
            description: Id of the user who created the wiki (`created_by_id`).
            example: 49290
          creator_name:
            type: string
            nullable: true
            example: Dana Lee
          matched_excerpt:
            type: string
            nullable: true
            description: 'Plain text from the page body around the first word of the
              query that matched, with "…" where it was cut. Null when only the title,
              description or tags matched.

              '
            example: "…overtime is paid at 1.5× after 40 hours in a week…"
          tags:
            type: array
            items:
              type: string
            example:
            - devops
            - runbook
    WikiSubTreeNode:
      description: 'One node in a wiki''s FULL sub-wiki tree — the card fields plus
        the page status and, RECURSIVELY, that node''s own published visible sub-wikis
        (empty at the leaves). Used by GET /wikis/{id} (`sub_wikis`).

        '
      allOf:
      - "$ref": "#/components/schemas/WikiCard"
      - type: object
        required:
        - status
        - sub_wikis
        properties:
          status:
            type: string
            enum:
            - draft
            - published
            - archived
            example: published
          sub_wikis:
            type: array
            description: This node's own sub-wikis, same shape, recursively.
            items:
              "$ref": "#/components/schemas/WikiSubTreeNode"
    IdeaComment:
      type: object
      description: |
        One comment (or reply) on an idea — text + @mentions only, with no attachments (matching the web composer, which posts a bare `comment[body]` with no file field). Threading is ONE level: a top-level comment inlines its direct replies, and a reply always has `replies: []`. Soft-deleted comments never appear.

        **`replies` is capped at 20 per comment.** `per_page` bounds the top-level list only, so a busy thread would otherwise return every reply it has. `replies_count` is the TRUE total (it can exceed `replies.length`) and `replies_truncated` tells you the array is partial — page the rest with `GET /ideas/{idea_id}/comments?parent_comment_id={id}`, whose default `per_page` is the same 20, so page 2 continues exactly where `replies` stopped.
      required:
      - id
      - idea_id
      - parent_comment_id
      - author
      - body
      - mentioned_user_ids
      - created_at
      - replies_count
      - replies_truncated
      - replies
      - can_edit
      - can_delete
      properties:
        id:
          type: integer
          example: 8842
        idea_id:
          type: integer
          example: 42
        parent_comment_id:
          type: integer
          nullable: true
          description: The comment this replies to; null for a top-level comment.
          example:
        author:
          type: object
          required:
          - id
          - name
          properties:
            id:
              type: integer
              example: 49290
            name:
              type: string
              example: Casey Poster
            photo:
              type: string
              nullable: true
              description: Absolute avatar URL.
        body:
          type: string
          description: The comment text, with raw `@[Name](mention:id)` mention tokens
            preserved so a client can linkify them.
          example: Great idea @[Dana Lee](mention:51102)
        mentioned_user_ids:
          type: array
          description: Deduped ids of the users tagged in `body`.
          items:
            type: integer
          example:
          - 51102
        edited_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-31T09:30:28Z'
        replies_count:
          type: integer
          description: The TRUE number of direct replies, from a COUNT — not the length
            of `replies`, which is capped at 20. Always 0 on a reply (one level of
            threading). Compare it with `replies.length`, or just read `replies_truncated`,
            to know whether there is more to fetch.
          example: 2
        replies_truncated:
          type: boolean
          description: True when `replies` holds only the first 20 of `replies_count`
            replies. Fetch the remainder with `GET /ideas/{idea_id}/comments?parent_comment_id={id}&page=2`.
            Always false on a reply.
          example: false
        replies:
          type: array
          maxItems: 20
          description: The direct replies, oldest first, CAPPED at 20 — see `replies_count`
            and `replies_truncated`. Always empty on a reply (one level of threading).
          items:
            "$ref": "#/components/schemas/IdeaComment"
        can_edit:
          type: boolean
          description: Whether the caller may PATCH this comment — the author alone,
            with no time window.
          example: true
        can_delete:
          type: boolean
          description: Whether the caller may delete it — the author OR an Ideas admin,
            with no time window.
          example: true
    IdeaVoteState:
      type: object
      description: 'An idea''s vote state after a cast/remove, returned by POST/DELETE
        /ideas/{idea_id}/vote. `vote_count` / `has_voted` match the feed card''s key
        names so a client can patch its cached card in place.

        '
      required:
      - idea
      properties:
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
        idea:
          type: object
          required:
          - id
          - vote_count
          - has_voted
          - voting_closed
          - vote_change_allowed
          properties:
            id:
              type: integer
              example: 42
            vote_count:
              type: integer
              description: The idea's total upvotes after this change (denormalized
                up_votes_count).
              example: 18
            has_voted:
              type: boolean
              description: Whether the CALLER now has an upvote on this idea.
              example: true
            voting_closed:
              type: boolean
              description: Whether the idea's voting-close date has passed (no further
                casts, and no removals).
              example: false
            vote_change_allowed:
              type: boolean
              description: 'Whether the caller may remove their upvote from THIS idea
                now: the "Allow removing an upvote" workspace setting is on AND the
                idea''s voting is not closed. When false, DELETE /ideas/{idea_id}/vote
                is refused (403 vote_change_disabled, or 422 voting_closed) — the
                client should hide the remove-vote affordance.'
              example: true
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    WikiCommentAttachment:
      type: object
      description: One file attached to a wiki comment.
      required:
      - id
      - filename
      - content_type
      - byte_size
      - image
      - url
      - download_url
      properties:
        id:
          type: integer
          example: 2634
        filename:
          type: string
          example: notes.txt
        content_type:
          type: string
          example: text/plain
        byte_size:
          type: integer
          example: 16
        image:
          type: boolean
          description: True for image blobs (render as a thumbnail).
          example: false
        url:
          type: string
          description: 'Absolute ActiveStorage URL for opening the attachment. An
            IMAGE is served `Content-Disposition: inline` so it can be previewed in
            place; every other file — a PDF above all — is served `Content-Disposition:
            attachment`, because a client with no built-in PDF renderer can neither
            display an inline PDF nor hand it to a download, so the tap does nothing
            at all.

            '
          example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/notes.txt?disposition=attachment
        download_url:
          type: string
          description: 'Absolute URL that ALWAYS responds `Content-Disposition: attachment`
            — the target for an explicit Download action, images included. Identical
            to `url` for every non-image attachment.

            '
          example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/notes.txt?disposition=attachment
    WikiComment:
      type: object
      description: 'A wiki comment. Top-level comments inline their direct replies
        in `replies` (ONE level — a reply''s own `replies` is always empty).

        '
      required:
      - id
      - wiki_id
      - parent_comment_id
      - author
      - body
      - mentioned_user_ids
      - created_at
      - replies_count
      - replies
      - reactions
      - attachments
      - can_edit
      - can_delete
      properties:
        id:
          type: integer
          example: 22
        wiki_id:
          type: integer
          example: 42
        parent_comment_id:
          type: integer
          nullable: true
          description: Null for a top-level comment; the parent's id for a reply.
          example:
        author:
          type: object
          nullable: true
          description: Null if the author was deleted.
          properties:
            id:
              type: integer
              example: 1884
            name:
              type: string
              example: Georgia Fitzpatrick
            avatar_url:
              type: string
              example: https://…/avatar.png
        body:
          type: string
          description: Comment text with raw `@[Name](mention:id)` mention tokens
            preserved.
          example: Great page @[Casey Poster](mention:49290)
        mentioned_user_ids:
          type: array
          description: Deduped user ids mentioned in the body.
          items:
            type: integer
          example:
          - 49290
        edited_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        replies_count:
          type: integer
          description: Number of direct replies (0 on a reply).
          example: 2
        replies:
          type: array
          description: Direct replies, oldest first. Empty on a reply (one level deep).
          items:
            "$ref": "#/components/schemas/WikiComment"
        reactions:
          type: object
          properties:
            total:
              type: integer
              example: 3
            top_emojis:
              type: array
              description: Up to 3 emojis by count, highest first.
              items:
                type: object
                properties:
                  emoji:
                    type: string
                    example: "\U0001F44D"
                  count:
                    type: integer
                    example: 2
            my_reactions:
              type: array
              description: Emojis the calling user reacted with.
              items:
                type: string
              example:
              - "\U0001F44D"
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/WikiCommentAttachment"
        can_edit:
          type: boolean
          description: 'Whether the CALLING user may edit this comment: an admin always
            may; otherwise only the author, and only within 15 minutes of posting.

            '
          example: true
        can_delete:
          type: boolean
          description: 'Whether the CALLING user may delete this comment: an admin
            always may; otherwise only the author, and only within 5 minutes of posting.

            '
          example: false
    ResponseMeta:
      type: object
      description: Standard response metadata
      additionalProperties: true
      properties:
        request_id:
          type: string
          description: Unique request identifier
        generated_at:
          type: string
          format: date-time
          description: Timestamp when the response was generated
        execution_time_ms:
          type: number
          description: Server processing time in milliseconds
        total_count:
          type: integer
          description: Total number of items
        total_pages:
          type: integer
          description: Total number of pages
        current_page:
          type: integer
          description: Current page number
        per_page:
          type: integer
          description: Number of items per page
    User:
      type: object
      properties:
        id:
          type: integer
          example: 1
        uid:
          type: string
          description: Unique employee identifier (mango_employee_id)
          example: E1234567
          nullable: true
        email:
          type: string
          format: email
          example: user@example.com
        first_name:
          type: string
          example: John
        last_name:
          type: string
          example: Doe
        full_name:
          type: string
          example: John Doe
        role:
          type: string
          example: employee
        active:
          type: boolean
          example: true
        avatar_url:
          type: string
          format: uri
          nullable: true
        phone:
          type: string
          nullable: true
        job_title:
          type: string
          nullable: true
          example: Software Engineer
        department:
          type: string
          nullable: true
          example: Engineering
        organizational_role:
          type: string
          nullable: true
          example: Senior Developer
        hire_date:
          type: string
          format: date-time
          nullable: true
          example: '2023-01-15T00:00:00Z'
        employment_status:
          type: string
          nullable: true
          example: full_time
        employee_type:
          type: string
          nullable: true
          example: Full-Time
        manager:
          type: object
          nullable: true
          properties:
            uid:
              type: string
              example: E1234567
            name:
              type: string
              example: Jane Smith
            email:
              type: string
              format: email
              example: jane.smith@example.com
        locations:
          type: array
          description: 'Enhanced location information with IDs (NEW: includes id,
            name, and address)'
          items:
            type: object
            properties:
              id:
                type: integer
                description: Location ID for reference in other API calls
                example: 265
              name:
                type: string
                description: Location name
                example: Issaquah Office
              address:
                type: string
                nullable: true
                description: Physical address of the location
                example: 1495 11th Avenue Northwest
        marketplace_apps:
          type: array
          description: Marketplace apps enabled for the user's business (NEW)
          items:
            type: object
            properties:
              id:
                type: integer
                description: Marketplace app ID
                example: 6
              name:
                type: string
                description: App display name
                example: Performance Management
              slug:
                type: string
                description: App URL slug
                example: employee-performance-management
              category:
                type: string
                description: App category
                example: performance
        termination_date:
          type: string
          format: date-time
          nullable: true
        termination_type:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          example: '2023-01-01T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
        last_activity_at:
          type: string
          format: date-time
          nullable: true
        preferences:
          type: object
          description: User preferences
    CurrentUser:
      description: |
        `GET /users/me` — the caller's own record (Api::V1::UsersController
        #build_base_user_data). Not the directory row (`User`): `department` here
        is an object, and `role`, `timezone`, `language` and the permission and
        feature-flag lists are the caller's own.
      type: object
      required:
      - id
      - first_name
      - last_name
      - full_name
      - active
      - department
      - language
      - language_set
      properties:
        id:
          type: integer
          example: 1
        email:
          type: string
          format: email
          nullable: true
          description: Null for a person with no email address.
        first_name:
          type: string
          nullable: true
          example: John
        last_name:
          type: string
          nullable: true
          example: Doe
        full_name:
          type: string
          example: John Doe
        role:
          type: string
          description: The caller's role name; an empty string when they have none.
          example: employee
        active:
          type: boolean
        avatar_url:
          type: string
          format: uri
          nullable: true
        avatar_updated_at:
          type: integer
          nullable: true
          description: Unix seconds of the current profile photo, for cache-busting;
            null without one.
        phone:
          type: string
          nullable: true
        timezone:
          type: string
          description: The time zone the caller runs on in this business.
          example: America/Los_Angeles
        time_zone_iana:
          type: string
          nullable: true
          description: |
            The same zone as a canonical IANA id, for Intl and moment-timezone,
            which know no Rails name. Null only when the zone can't be resolved.
          example: America/Los_Angeles
        timezone_set:
          type: boolean
          description: Whether the caller chose a time zone themselves.
        employee_type:
          type: string
          nullable: true
          example: Full-Time
        hire_date:
          type: string
          format: date
          nullable: true
        job_title:
          type: string
          nullable: true
          description: The job title this business shows for the caller.
          example: Software Engineer
        department:
          type: object
          nullable: true
          description: The caller's organizational department in this business, as
            its id and name; null when they have none here.
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
              example: Engineering
        language:
          type: string
          description: |
            The language content reaches the caller in — their
            own setting, else their place's language, else the
            company default. Never null (Knowledge Layer P4).
          example: en
        language_set:
          type: boolean
          description: |
            Whether the caller chose a language themselves;
            `false` is the profile's "Not set" (`language`
            then follows their place or the company).
        preferences:
          type: object
          description: The caller's stored preferences.
        permissions:
          type: array
          items:
            type: string
          description: User's permissions
        feature_flags:
          type: object
          description: Feature flags for the user
        custom_fields:
          type: array
          description: |
            The caller's answers to the organization's own profile fields
            (People › Profile Fields) — the ones their profile shows them, in
            the profile's order. Empty while custom fields are off.
          items:
            "$ref": "#/components/schemas/ProfileCustomField"
        last_activity_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ProfileCustomField:
      description: |
        One of the organization's own profile fields (People › Profile Fields)
        on a person, as the caller may see it there: an administrator sees every
        field; the person, their manager and coworkers see the fields the
        field's visibility gives them. Section headings carry no answer and are
        not listed.
      type: object
      required:
      - field_name
      - label
      - field_type
      - category
      - value
      - display_value
      - required
      - editable
      properties:
        field_name:
          type: string
          example: tshirt_size
        label:
          type: string
          example: T-shirt size
        field_type:
          type: string
          description: One of text, textarea, number, email, phone, url, select, multiselect,
            radio, checkbox, date, datetime, time, file, currency, rating.
          example: select
        category:
          type: string
          description: The profile section the field sits in.
          example: additional_info
        value:
          nullable: true
          description: The stored answer (a string, or an array for a multiselect);
            null when unanswered.
          example: L
        display_value:
          type: string
          nullable: true
          description: The answer as the profile shows it (a date formatted, a checkbox
            Yes/No, a rating as stars); null when unanswered.
          example: L
        required:
          type: boolean
        editable:
          type: boolean
          description: Whether the caller may change this answer.
    UserDetail:
      description: |
        `GET /users/{id}` and `GET /users/by_email/{email}` — the list row
        (`User`) plus the HRIS detail below. `manager` here also carries
        `job_title`.
      allOf:
      - "$ref": "#/components/schemas/User"
      - type: object
        properties:
          direct_reports_count:
            type: integer
          skills_count:
            type: integer
          certifications_count:
            type: integer
          organizational_department:
            type: object
            nullable: true
            properties:
              id:
                type: integer
              name:
                type: string
          legal_entity:
            type: object
            nullable: true
            description: |
              The employing legal entity (Knowledge Layer X5): the person's own
              override, else the nearest one set on their location or a place
              above it. Null when none applies.
            properties:
              id:
                type: integer
              name:
                type: string
                example: Acme Retail GmbH
              country_code:
                type: string
                nullable: true
                description: ISO 3166-1 alpha-2, when the entity names one.
                example: DE
          flsa_status:
            type: string
            nullable: true
          worker_classification:
            type: string
            nullable: true
          time_zone:
            type: string
            nullable: true
          language:
            type: string
            nullable: true
            description: |
              The person's stored language SETTING — null means "Not set" (they
              follow their place's language, else the company default). A
              profile field, not the resolved language.
          external_employee_id:
            type: string
            nullable: true
          custom_fields:
            type: array
            description: |
              The organization's own profile fields on this person that the
              caller may see there. Empty while custom fields are off, and for
              a token bound to no person.
            items:
              "$ref": "#/components/schemas/ProfileCustomField"
    Business:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        subdomain:
          type: string
        timezone:
          type: string
          example: UTC
        logo_url:
          type: string
          format: uri
          nullable: true
        logo_background_color:
          type: string
          nullable: true
          description: HEX colour behind the logo on the sign-in screen (`GET /auth/login_config`
            only), composited over white; null when none is set or the backing is
            not a plain colour
        login_message:
          type: string
          nullable: true
          description: The organization's sign-in message (help line), plain text
            with line breaks (`GET /auth/login_config` only); null when none is set
        branding:
          type: object
          nullable: true
          description: Branding color configuration for mobile theming (HEX format).
            primary_button_color, status_success_color and status_error_color are
            sent readable under white text, as the phone web paints them.
          properties:
            primary_button_color:
              type: string
              example: "#2e63b3"
            secondary_button_color:
              type: string
              example: "#6c757d"
            status_success_color:
              type: string
              example: "#198754"
            status_error_color:
              type: string
              example: "#dc3545"
            status_warning_color:
              type: string
              example: "#ffc107"
            status_info_color:
              type: string
              example: "#0dcaf0"
            mobile_header_background:
              type: string
              example: "#2e63b3"
            mobile_header_text:
              type: string
              example: "#FFFFFF"
            mobile_footer_background:
              type: string
              example: "#FFFFFF"
            mobile_footer_icon_active:
              type: string
              example: "#2e63b3"
            mobile_footer_icon_inactive:
              type: string
              example: "#6c757d"
        settings:
          type: object
          description: Business settings for mobile app
    Shift:
      type: object
      properties:
        id:
          type: integer
          example: 123
        managed_by:
          type: string
          nullable: true
          example: UKG
          description: 'The workforce management system of record that owns this shift
            ("Managed in UKG"), or null. A managed shift is read-only here: coverage
            requests and edits answer 422 `externally_managed`.'
        name:
          type: string
          example: Morning Shift
        description:
          type: string
          nullable: true
        start_time:
          type: string
          format: date-time
          example: '2025-01-27T09:00:00Z'
        end_time:
          type: string
          format: date-time
          example: '2025-01-27T17:00:00Z'
        formatted_date:
          type: string
          description: Human-readable date format
          example: Monday, Jan 27
        formatted_time:
          type: string
          description: Human-readable time range
          example: 9:00 AM - 5:00 PM
        status:
          type: string
          enum:
          - scheduled
          - completed
          - cancelled
          - open
          example: scheduled
        urgent:
          type: boolean
          description: Whether this shift is marked as urgent
          example: false
        needs_coverage:
          type: boolean
          description: Whether this shift needs coverage
          example: false
        can_list_for_coverage:
          type: boolean
          description: Whether the current user can list this shift for coverage in
            the marketplace
          example: true
        is_already_listed:
          type: boolean
          description: Whether this shift is already listed in the marketplace by
            the current user
          example: false
        required_users:
          type: integer
          description: Number of users required for this shift
        location:
          type: object
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Downtown Store
            address:
              type: string
              nullable: true
            phone:
              type: string
              nullable: true
        users:
          type: array
          items:
            "$ref": "#/components/schemas/User"
        available_actions:
          type: array
          description: Actions available to the current user for this shift
          items:
            type: string
            enum:
            - view_details
            - request_coverage
            - report_lateness
            - view_teammates
            - claim
          example:
          - view_details
          - request_coverage
          - view_teammates
        teammates:
          type: array
          description: Other users assigned to this shift (when include=teammates)
          nullable: true
          items:
            type: object
            properties:
              id:
                type: integer
                description: User ID
              name:
                type: string
                description: User full name
              job_title:
                type: string
                nullable: true
                description: User's job title/role
              profile_photo_url:
                type: string
                nullable: true
                description: Full-size profile photo URL (200x200) or avatar placeholder
              profile_photo_thumbnail_url:
                type: string
                nullable: true
                description: Thumbnail profile photo URL (40x40) optimized for list
                  views
        listed_by:
          type: object
          nullable: true
          description: User who listed this shift in the marketplace (teammate format,
            present for marketplace shifts)
          properties:
            id:
              type: integer
              description: User ID
            name:
              type: string
              description: User full name
            job_title:
              type: string
              nullable: true
              description: User's job title/role
            profile_photo_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or avatar placeholder
            profile_photo_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        picked_by:
          type: object
          nullable: true
          description: User who claimed/picked up this shift from the marketplace
            (teammate format, present when shift was claimed)
          properties:
            id:
              type: integer
              description: User ID
            name:
              type: string
              description: User full name
            job_title:
              type: string
              nullable: true
              description: User's job title/role
            profile_photo_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or avatar placeholder
            profile_photo_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        marketplace_info:
          type: object
          nullable: true
          description: Marketplace listing information (present when shift has marketplace
            listing)
          properties:
            has_listing:
              type: boolean
              description: Whether this shift has an active marketplace listing
              example: true
            listing_id:
              type: integer
              description: ID of the marketplace listing
              example: 880
            listing_status:
              type: string
              enum:
              - open
              - filled
              - closed
              - cancelled
              description: Current status of the marketplace listing
              example: open
            lister_status:
              type: string
              enum:
              - open
              - filled
              - traded
              - closed
              - cancelled
              description: 'Present only when the listing is the caller''s own. The
                status as its lister reads it: listing_status, except a listing filled
                by an accepted trade is traded (filled = picked up). Switch on this
                key, never on lister_status_label.'
              example: traded
            lister_status_label:
              type: string
              description: Present only when the listing is the caller's own. The
                word the web and phone pages show the lister (Available, Covered,
                Traded, Closed, Cancelled), in the language the client asks for (the
                locale param, else Accept-Language, else the person's own language).
              example: Traded
            listing_type:
              type: string
              enum:
              - pickup
              - trade_only
              - both
              description: Type of marketplace listing - pickup (direct claim), trade_only
                (requires application/trade), or both (accepts either)
              example: pickup
            price:
              type: number
              format: float
              description: Price offered for the shift
              example: 0.01
            currency:
              type: string
              description: Currency code (e.g., USD)
              example: USD
            listed_by:
              type: integer
              description: User ID of the person who listed the shift (legacy field,
                use listed_by object instead)
              example: 1487
            listed_at:
              type: string
              format: date-time
              description: When the shift was listed in the marketplace
              example: '2025-12-01T10:05:00Z'
        notes:
          type: string
          nullable: true
          description: Shift notes (when include=notes)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    AttendanceRecord:
      type: object
      properties:
        managed_by:
          type: string
          nullable: true
          example: UKG
          description: The workforce management system of record whose punch this
            is, or null. A managed punch (and its breaks) is read-only here.
        id:
          type: integer
        user_id:
          type: integer
        shift_id:
          type: integer
        business_id:
          type: integer
        check_in_time:
          type: string
          format: date-time
          nullable: true
        check_out_time:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum:
          - pending
          - on_time
          - late
          - missed
          - completed
          - absence_reported
          - excused
          - no_show
        location_verified:
          type: boolean
          default: false
        photo_verification_required:
          type: boolean
          default: false
        photo_verification_failed_reason:
          type: string
          nullable: true
        is_unknown_device:
          type: boolean
          default: false
        requires_review:
          type: boolean
          default: false
        review_reason:
          type: string
          nullable: true
        review_reason_label:
          type: string
          nullable: true
          description: The words people see for `review_reason` ("Missing-punch request"
            for `missing_punch_request`); null when there is no reason.
        reviewed_by_user_id:
          type: integer
          nullable: true
        reviewed_at:
          type: string
          format: date-time
          nullable: true
        excused_by_user_id:
          type: integer
          nullable: true
        excused_at:
          type: string
          format: date-time
          nullable: true
        absence_report_id:
          type: integer
          nullable: true
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        user:
          "$ref": "#/components/schemas/User"
        shift:
          allOf:
          - "$ref": "#/components/schemas/Shift"
          nullable: true
          description: 'The record''s shift. A SUBSET of the Shift schema: id, name,
            start_time, end_time, formatted_date, formatted_time — plus status, location
            and location_department when `include=shift_details` is requested. `formatted_date`
            ("Wednesday, Aug 05") and `formatted_time` ("01:30 PM - 09:30 PM") are
            rendered in the SHIFT''s own timezone, are byte-identical to what `GET
            /shifts` returns for the same shift.id, and do not vary with the caller''s
            profile timezone — clients should prefer them over formatting `start_time`
            locally, which has no usable zone once an SDK decodes it to a bare instant.
            `formatted_date` always names the shift''s START day, so it is correct
            for a shift crossing midnight.'
        break_records:
          type: array
          items:
            "$ref": "#/components/schemas/BreakRecord"
    BreakRecord:
      type: object
      properties:
        id:
          type: integer
        attendance_record_id:
          type: integer
        break_type_id:
          type: integer
        start_time:
          type: string
          format: date-time
          nullable: true
        end_time:
          type: string
          format: date-time
          nullable: true
        duration_minutes:
          type: integer
          nullable: true
        required_duration_minutes:
          type: integer
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - skipped
          - interrupted
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
    MissingPunchRequest:
      type: object
      description: 'An employee-submitted punch correction. Stored as an AttendanceRecord
        with punch_source=''missing_punch_request'' and flagged for manager review
        — it is a REQUEST, not an approved punch, until a manager acts on it.

        '
      properties:
        id:
          type: integer
        status:
          type: string
          description: "`pending_review` until a manager acts; afterwards the underlying
            attendance status.\n"
        pending_review:
          type: boolean
        check_in_time:
          type: string
          format: date-time
        check_out_time:
          type: string
          format: date-time
        reason:
          type: string
          description: The employee's stated reason for the correction.
        reviewed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        can_cancel:
          type: boolean
          description: 'Whether DELETE would succeed — true only while no manager
            has acted. Use it to decide whether to show a cancel control instead of
            discovering the rule from a 422.

            '
        shift_id:
          type: integer
          description: Detailed responses only (create). The supporting ad-hoc shift.
        location_name:
          type: string
          nullable: true
          description: Detailed responses only.
        breaks:
          type: array
          description: 'Detailed responses only. Required break types the employee
            did not submit appear with status `skipped`.

            '
          items:
            type: object
            properties:
              id:
                type: integer
              break_type_id:
                type: integer
              break_type_name:
                type: string
              start_time:
                type: string
                format: date-time
                nullable: true
              end_time:
                type: string
                format: date-time
                nullable: true
              duration_minutes:
                type: integer
                nullable: true
              status:
                type: string
    ShiftFeedback:
      type: object
      properties:
        id:
          type: integer
          example: 789
        attendance_record_id:
          type: integer
          example: 123
          description: ID of the associated attendance record
        user_id:
          type: integer
          example: 456
          description: ID of the user who submitted feedback
        rating:
          type: integer
          minimum: 1
          maximum: 5
          example: 4
          description: Overall shift rating (1-5 stars)
        feedback_text:
          type: string
          nullable: true
          example: Great shift, but the workspace was a bit noisy.
          description: Detailed feedback text (optional)
        shift_difficulty:
          type: integer
          minimum: 1
          maximum: 5
          nullable: true
          example: 3
          description: How difficult was the shift (1=Easy, 5=Very Hard)
        would_work_again:
          type: boolean
          nullable: true
          example: true
          description: Would the user work this shift again
        can_edit:
          type: boolean
          example: true
          description: Whether the feedback can still be edited (within 24 hours)
        edit_window_expires_at:
          type: string
          format: date-time
          example: '2025-10-11T16:00:00Z'
          description: When the edit window expires (24 hours from submission)
        created_at:
          type: string
          format: date-time
          example: '2025-10-10T16:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2025-10-10T16:00:00Z'
        attendance_record:
          "$ref": "#/components/schemas/AttendanceRecord"
        shift:
          "$ref": "#/components/schemas/Shift"
        user:
          "$ref": "#/components/schemas/User"
    ShiftMarketplaceListing:
      type: object
      description: A marketplace listing for shifts (pickup, trade_only, or both)
      properties:
        id:
          type: integer
          example: 1
        listing_type:
          type: string
          enum:
          - pickup
          - trade_only
          - both
          example: pickup
          description: Type of listing - pickup (direct claim), trade_only (requires
            application), or both
        status:
          type: string
          enum:
          - open
          - filled
          - closed
          - cancelled
          example: open
        price:
          type: number
          nullable: true
          example: 0.01
        currency:
          type: string
          example: USD
        notes:
          type: string
          nullable: true
          example: Need someone to cover this shift
        urgent:
          type: boolean
          description: Whether this listing is marked as urgent
          example: false
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        claimed_at:
          type: string
          format: date-time
          nullable: true
        shift:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            start_time:
              type: string
              format: date-time
            end_time:
              type: string
              format: date-time
            date:
              type: string
              format: date
            formatted_date:
              type: string
            formatted_time:
              type: string
            location:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        claimed_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        can_claim:
          type: boolean
          description: Whether current user can claim this listing (false while their
            own request on it waits)
        pending_request:
          type: object
          nullable: true
          description: The current user's own pickup request on this listing's shift
            that is still waiting for approval; withdraw it with POST /api/v1/shift_marketplace/listings/{id}/withdraw_claim
          properties:
            id:
              type: integer
            requested_at:
              type: string
              format: date-time
              nullable: true
        accepts_applications:
          type: boolean
          description: Whether this listing accepts applications (trade_only or both)
        is_trade_only:
          type: boolean
          description: Whether this is a trade-only listing
        has_applied:
          type: boolean
          description: Whether current user has applied to this listing
        application_status:
          type: string
          nullable: true
          enum:
          - pending
          - accepted
          - rejected
          - withdrawn
          description: Current user's application status (if applied)
        application_id:
          type: integer
          nullable: true
          description: Current user's application ID (if applied)
        is_owner:
          type: boolean
          description: Whether current user owns this listing
        lister_status:
          type: string
          enum:
          - open
          - filled
          - traded
          - closed
          - cancelled
          description: Owner rows only (is_owner true). The status as its lister reads
            it — status, except a listing filled by an accepted trade is traded (filled
            = picked up). Switch on this key, never on lister_status_label.
          example: traded
        lister_status_label:
          type: string
          description: Owner rows only (is_owner true). The word the web and phone
            pages show the lister (Available, Covered, Traded, Closed, Cancelled),
            in the language the client asks for (the locale param, else Accept-Language,
            else the person's own language).
          example: Traded
        can_edit:
          type: boolean
        can_delete:
          type: boolean
        applications:
          type: array
          description: Owner-only application summaries for this listing
          items:
            type: object
            properties:
              id:
                type: integer
              status:
                type: string
                enum:
                - pending
                - accepted
                - rejected
                - withdrawn
              notes:
                type: string
                nullable: true
              created_at:
                type: string
                format: date-time
              offered_shift:
                type: object
                nullable: true
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  start_time:
                    type: string
                    format: date-time
                  end_time:
                    type: string
                    format: date-time
                  formatted_date:
                    type: string
                  formatted_time:
                    type: string
                  location:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
              applicant:
                type: object
                nullable: true
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  avatar_url:
                    type: string
                    nullable: true
                  avatar_thumbnail_url:
                    type: string
                    nullable: true
        applications_count:
          type: integer
          description: Total applications (owner only)
        pending_applications_count:
          type: integer
          description: Pending applications count (owner only)
    ShiftMarketplaceApplication:
      type: object
      description: An application to a trade listing
      properties:
        id:
          type: integer
          example: 1
        status:
          type: string
          enum:
          - pending
          - accepted
          - rejected
          - withdrawn
          example: pending
        status_label:
          type: string
          description: The status as the app shows it. An application the app closed
            (its listing filled, closed or cancelled, or the offered shift gone) has
            status rejected and reads "No Longer Available".
          example: Pending
        notes:
          type: string
          nullable: true
          example: I can work this shift
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        listing:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            listing_type:
              type: string
            status:
              type: string
            shift:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
                start_time:
                  type: string
                  format: date-time
                end_time:
                  type: string
                  format: date-time
                formatted_date:
                  type: string
                formatted_time:
                  type: string
                location:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
            created_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
                email:
                  type: string
                avatar_url:
                  type: string
                  nullable: true
                  description: Full-size profile photo URL (200x200) or fallback to
                    initials-based avatar
                avatar_thumbnail_url:
                  type: string
                  nullable: true
                  description: Thumbnail profile photo URL (40x40) optimized for list
                    views
        applicant:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        is_applicant:
          type: boolean
          description: Whether current user is the applicant
        is_listing_owner:
          type: boolean
          description: Whether current user owns the listing
        can_accept:
          type: boolean
          description: Whether current user can accept this application
        can_reject:
          type: boolean
          description: Whether current user can reject this application
        can_withdraw:
          type: boolean
          description: Whether current user can withdraw this application
    ShiftDirectOffer:
      type: object
      description: A direct peer-to-peer shift offer
      properties:
        id:
          type: integer
          example: 1
        status:
          type: string
          enum:
          - pending
          - accepted
          - declined
          - expired
          - cancelled
          example: pending
        cancellation_reason:
          type: string
          nullable: true
          description: 'Why the app withdrew a cancelled offer (for example, the shift
            was filled or removed). Null when the offer is not cancelled, or when
            its sender cancelled it.

            '
        notes:
          type: string
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        shift:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            start_time:
              type: string
              format: date-time
            end_time:
              type: string
              format: date-time
            formatted_date:
              type: string
            formatted_time:
              type: string
            location:
              type: string
              nullable: true
        from_user:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        to_user:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
            avatar_url:
              type: string
              nullable: true
              description: Full-size profile photo URL (200x200) or fallback to initials-based
                avatar
            avatar_thumbnail_url:
              type: string
              nullable: true
              description: Thumbnail profile photo URL (40x40) optimized for list
                views
        is_sender:
          type: boolean
          description: Whether current user sent this offer
        is_recipient:
          type: boolean
          description: Whether current user received this offer
        can_accept:
          type: boolean
          description: Whether current user can accept this offer
        can_decline:
          type: boolean
          description: Whether current user can decline this offer
        can_cancel:
          type: boolean
          description: Whether current user can cancel this offer (sender only, pending
            offers)
    BreakType:
      type: object
      properties:
        id:
          type: integer
        business_id:
          type: integer
        name:
          type: string
        duration_minutes:
          type: integer
        is_required:
          type: boolean
        is_paid:
          type: boolean
        description:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    HttpEnhancementMeta:
      type: object
      description: HTTP protocol enhancements for client performance and dynamic behavior
      properties:
        http:
          type: object
          description: HTTP enhancement metadata
          properties:
            caching:
              type: object
              description: Intelligent caching information
              properties:
                etag:
                  type: string
                  description: Entity tag for content validation
                  example: W/"abc123-def456"
                max_age:
                  type: integer
                  description: Cache max-age in seconds
                  example: 300
                private:
                  type: boolean
                  description: Whether response should be cached privately
                  example: true
                revalidate:
                  type: boolean
                  description: Whether cache must revalidate with server
                  example: false
                vary:
                  type: array
                  description: Headers that affect caching
                  items:
                    type: string
                  example:
                  - Accept
                  - Authorization
                cacheable:
                  type: boolean
                  description: Whether response is cacheable
                  example: true
            navigation:
              type: object
              description: Dynamic navigation and preload hints
              properties:
                next_actions:
                  type: array
                  description: Suggested next actions for the client
                  items:
                    type: object
                    properties:
                      rel:
                        type: string
                        description: Relationship type
                        example: edit-profile
                      href:
                        type: string
                        description: URL for the action
                        example: "/api/v1/users/me"
                      method:
                        type: string
                        description: HTTP method
                        example: PATCH
                      title:
                        type: string
                        description: Human-readable action title
                        example: Edit Profile
                      preload:
                        type: boolean
                        description: Whether to preload this resource
                        example: true
                      prefetch:
                        type: boolean
                        description: Whether to prefetch this resource
                        example: false
                redirects:
                  type: object
                  description: Conditional redirect suggestions
                  properties:
                    conditional:
                      type: object
                      description: Conditional redirects based on user state
                      additionalProperties:
                        type: string
                      example:
                        incomplete_profile: "/api/v1/users/me/complete"
                        pending_verification: "/api/v1/auth/verify"
                preload_hints:
                  type: array
                  description: Resources to preload
                  items:
                    type: object
                    properties:
                      resource:
                        type: string
                        example: "/api/v1/businesses/settings"
                      as:
                        type: string
                        example: fetch
                      priority:
                        type: string
                        enum:
                        - high
                        - medium
                        - low
                        example: high
            performance:
              type: object
              description: Performance metrics and optimization info
              properties:
                compression:
                  type: string
                  description: Compression type used
                  enum:
                  - gzip
                  - deflate
                  - none
                  example: gzip
                response_time_ms:
                  type: number
                  description: Server response time in milliseconds
                  example: 45.2
                cache_status:
                  type: string
                  description: Cache status for this request
                  enum:
                  - hit
                  - miss
                  - conditional
                  - bypass
                  example: hit
                estimated_size_bytes:
                  type: integer
                  description: Estimated response size in bytes
                  example: 2048
                optimization_score:
                  type: integer
                  description: Performance optimization score (0-100)
                  example: 85
            client_hints:
              type: object
              description: Hints for client behavior and capabilities
              properties:
                api_version:
                  type: string
                  description: Current API version
                  example: '1.0'
                rate_limit:
                  type: object
                  description: Rate limiting information
                  properties:
                    remaining:
                      type: integer
                      description: Requests remaining in current window
                      example: 98
                    reset:
                      type: integer
                      description: Unix timestamp when rate limit resets
                      example: 1640995200
                    limit:
                      type: integer
                      description: Total requests allowed per window
                      example: 100
                deprecations:
                  type: array
                  description: Deprecation warnings for used features
                  items:
                    type: object
                    properties:
                      feature:
                        type: string
                        example: API version 0.9
                      deprecated_since:
                        type: string
                        format: date
                        example: '2024-01-01'
                      removal_date:
                        type: string
                        format: date
                        example: '2024-12-31'
                      replacement:
                        type: string
                        example: API version 1.0+
                preferred_format:
                  type: string
                  description: Recommended response format for client
                  example: application/json
                capabilities:
                  type: array
                  description: Detected client capabilities
                  items:
                    type: string
                  example:
                  - compression
                  - conditional_requests
                  - mobile_optimized
    MarketplaceApp:
      type: object
      properties:
        id:
          type: integer
          description: Unique identifier for the marketplace app
          example: 42
        slug:
          type: string
          description: URL-friendly identifier for the app
          example: employee-performance-management
        name:
          type: string
          description: Display name of the app
          example: Employee Performance Management
        description:
          type: string
          description: Short description of the app
          example: Comprehensive performance review and goal management system
        icon:
          type: object
          description: App icon information
          properties:
            url:
              type: string
              description: URL to the app icon
              example: https://example.com/icon.png
            type:
              type: string
              enum:
              - icon_class
              - url
              - default
              description: |
                Type of icon source. This is the authoritative, versioned contract —
                the same three values on every app-listing endpoint:
                - icon_class: Font Awesome 5 class carried in `url`
                - url: HTTP(S) URL or asset path carried in `url`. Icons stored as
                  Active Storage attachments report as `url` too — where the server
                  keeps the image is not part of the contract.
                - default: built-in fallback asset path
                Adding a value is a BREAKING change (clients decode this as a strict
                enum), so it requires an API version bump. Server-side source of
                truth: MobileViewSupport::API_ICON_TYPES.
              example: url
        category:
          type: string
          description: App category
          example: hr
        version:
          type: string
          description: Current version of the app
          example: 2.1.0
        featured:
          type: boolean
          description: Whether the app is featured
          example: false
        sort_order:
          type: integer
          description: The app's place in the business's app order (1 = first), the
            order the list is returned in
          example: 10
        url:
          type: string
          description: Launch URL for the app
          example: "/apps/employee-performance-management"
        configuration:
          type: object
          description: Business-specific configuration for this app
          additionalProperties: true
          example:
            enable_self_reviews: true
            review_cycle_months: 12
        enabled_at:
          type: string
          format: date-time
          description: When the app was enabled for this business
          example: '2024-01-15T10:30:00Z'
        metadata:
          type: object
          description: Additional app metadata
          additionalProperties: true
          example:
            requires_training: false
            setup_time_minutes: 30
      required:
      - id
      - slug
      - name
      - category
      - url
    CoreApp:
      type: object
      properties:
        id:
          type: string
          description: Unique identifier for the core app
          example: shifts_scheduling
        slug:
          type: string
          description: URL-friendly identifier for the app
          example: shifts_scheduling
        name:
          type: string
          description: Display name of the core app
          example: Shifts & Scheduling
        description:
          type: string
          description: Description of the core app functionality
          example: Complete shift scheduling and management system with team calendar
        icon:
          type: object
          description: App icon information
          properties:
            url:
              type: string
              description: Icon class or URL
              example: fas fa-calendar-alt
            type:
              type: string
              enum:
              - icon_class
              - url
              - default
              description: |
                Type of icon source. This is the authoritative, versioned contract —
                the same three values on every app-listing endpoint:
                - icon_class: Font Awesome 5 class carried in `url`
                - url: HTTP(S) URL or asset path carried in `url`. Icons stored as
                  Active Storage attachments report as `url` too — where the server
                  keeps the image is not part of the contract.
                - default: built-in fallback asset path
                Adding a value is a BREAKING change (clients decode this as a strict
                enum), so it requires an API version bump. Server-side source of
                truth: MobileViewSupport::API_ICON_TYPES.
              example: icon_class
        category:
          type: string
          description: App category (always 'core' for core apps)
          example: core
        version:
          type: string
          description: Current version of the app
          example: 1.0.0
        color:
          type: string
          description: UI color theme for the app
          example: primary
        enabled:
          type: boolean
          description: Whether the core app is enabled for this business
          example: true
        features:
          type: array
          description: List of key features provided by this core app
          items:
            type: string
          example:
          - Shift Creation
          - Team Calendar
          - Availability Management
          - Schedule Templates
        url:
          type: string
          description: Direct URL path to access the core app
          example: "/shifts"
        metadata:
          type: object
          description: Additional metadata about the core app
          properties:
            app_type:
              type: string
              example: core
            always_available:
              type: boolean
              example: true
            basic_workforce_feature:
              type: boolean
              example: true
            advanced_feature:
              type: boolean
              example: false
          additionalProperties: true
      required:
      - id
      - slug
      - name
      - category
      - enabled
      - url
    ConsolidatedApp:
      type: object
      description: Unified app schema combining both core and marketplace apps
      properties:
        id:
          oneOf:
          - type: string
          - type: integer
          description: Unique identifier (string for core apps, integer for marketplace
            apps)
          example: shifts_scheduling
        slug:
          type: string
          description: URL-friendly identifier for the app
          example: shifts_scheduling
        name:
          type: string
          description: Display name of the app
          example: Shifts & Scheduling
        description:
          type: string
          description: Description of the app functionality
          example: Complete shift scheduling and management system
        type:
          type: string
          enum:
          - core
          - marketplace
          description: Type of app (core or marketplace)
          example: core
        icon:
          type: object
          description: |
            App icon information. For mobile clients, always use the `url` field with `type: icon_class`.
            Font Awesome 5 icon classes are provided for all apps to ensure consistent mobile rendering.
          properties:
            url:
              type: string
              description: |
                Font Awesome icon class (when type is "icon_class") or URL/path (when type is "url" or "default").
                Mobile clients should always use this field for rendering icons.
              example: fas fa-calendar-alt
            type:
              type: string
              enum:
              - icon_class
              - url
              - default
              description: |
                Type of icon source. This is the authoritative, versioned contract —
                the same three values on every app-listing endpoint:
                - icon_class: Font Awesome 5 class (recommended for mobile)
                - url: Web URL or asset path. Icons stored as Active Storage
                  attachments report as `url` too — where the server keeps the image
                  is not part of the contract.
                - default: built-in fallback asset path
                Adding a value is a BREAKING change (clients decode this as a strict
                enum), so it requires an API version bump. Server-side source of
                truth: MobileViewSupport::API_ICON_TYPES.
              example: icon_class
            web_url:
              type: string
              nullable: true
              description: |
                Original SVG/image URL for web clients. Only present when the app has a custom SVG icon
                that was converted to a Font Awesome class for mobile. Web clients can use this field
                to display the original custom icon if preferred.
              example: "/assets/icons/employee-performance-management.svg"
        nav:
          type: object
          description: |
            The app as navigation shows it — the short word and the glyph the
            web rail and the phone web's tab bar draw (the app's manifest:
            SidebarHelper#app_rail_label / #sidebar_app_icon). A tab bar draws
            these; `name` and `icon` stay the app grid's.
          properties:
            label:
              type: string
              example: Comms
            icon:
              type: string
              description: Font Awesome class.
              example: fas fa-bullhorn
            icon_type:
              type: string
              enum:
              - icon_class
              description: Always `icon_class` (MobileViewSupport::API_ICON_TYPES).
              example: icon_class
        category:
          type: string
          description: App category grouping (e.g., "HR Management", "Workforce Management")
          example: Workforce Management
        version:
          type: string
          description: Current version of the app
          example: 1.0.0
        color:
          type: string
          description: UI color theme for the app
          example: primary
        enabled:
          type: boolean
          description: Whether the app is enabled for this business
          example: true
        featured:
          type: boolean
          description: Whether the app is featured (marketplace apps only)
          example: false
        sort_order:
          type: integer
          description: The app's place in the list as returned (1 = first) — the business's
            app order, after the viewer's pinned apps in the navigation format
          example: 1
        url:
          type: string
          description: Direct URL path to access the app
          example: "/shifts"
        features:
          type: array
          description: List of key features provided by this app
          items:
            type: string
          example:
          - Shift Creation
          - Team Calendar
          - Availability Management
        configuration:
          type: object
          description: Business-specific configuration (marketplace apps only)
          additionalProperties: true
          example: {}
        enabled_at:
          type: string
          format: date-time
          description: When the app was enabled (marketplace apps only)
          example: '2024-01-15T10:30:00Z'
        metadata:
          type: object
          description: Additional metadata about the app
          additionalProperties: true
          example:
            app_type: core
            always_available: true
        pinned:
          type: boolean
          description: |
            Whether this app is pinned for the calling user (mirrors the web
            sidebar's pinned list). **Only present when
            `include_navigation=true`** — `true` for every entry in
            `pinned_apps`, `false` for every entry in `apps`.
          example: true
        has_mobile_view:
          type: boolean
          description: |
            Whether the app has a mobile-optimized (`/m/...`) view. Only present
            when `include_navigation=true`. Mobile clients never see an app with
            `false` here — those entries are filtered out of both lists.
          example: true
        mobile_url:
          type: string
          nullable: true
          description: |
            The app's `/m/...` entry point, or `null` when it has no mobile view.
            Only present when `include_navigation=true`.

            Gotcha: on mobile, when an app's navigation collapses to a single
            child page (see `navigation_items`), this is repointed at that child
            path and `navigation_items` comes back empty — so always prefer this
            value over deriving a path from `slug`.
          example: "/m/apps/ideas"
        unread_count:
          type: integer
          description: |
            Unread badge count for the app tile. Opt-in via `?dashboard=true` or
            any `?include=` value containing `dashboard`, and emitted ONLY for
            the apps that have an unread concept (`chat`, `news-feed`) and only
            when that entry is enabled. Counts come from the same source as
            `GET /api/v1/home`'s badges, so the two cannot drift. Best-effort:
            if a count query fails, the field is omitted for that app rather
            than failing the request.
          example: 7
        unacknowledged_recognitions:
          type: object
          description: |
            The recognitions the caller has RECEIVED and not yet seen — the
            confetti feed. Present on the **`recognitions`** entry only, and only
            when that entry is enabled; every other app omits it.

            Unlike `unread_count` this is **NOT behind `?dashboard=true`**: a
            badge a client forgot to ask for is a number it can fetch later, but
            a celebration it never hears about is simply never shown. Always
            present (as `{count: 0, items: []}`) once the node is there, so a
            client can tell "nothing to celebrate" from "this server predates the
            feature".

            Bounded to the newest 20 received in the last 30 days, newest first
            — the client celebrates once, not once per item. Settle each item
            afterwards with `POST /api/v1/recognitions/{posts,awards}/{id}/acknowledge`
            (or by opening its detail screen, which acknowledges on its own), or
            the next launch celebrates the same ones again. Best-effort: if the
            lookup fails the field is omitted rather than failing the app list.
          properties:
            count:
              type: integer
              description: How many items `items` carries (already capped at 20).
              example: 2
            items:
              type: array
              maxItems: 20
              items:
                type: object
                properties:
                  type:
                    type: string
                    enum:
                    - recognition_post
                    - award
                    description: Which acknowledge path settles it — `recognition_post`
                      → `/recognitions/posts/{id}/acknowledge`, `award` → `/recognitions/awards/{id}/acknowledge`.
                    example: recognition_post
                  id:
                    type: integer
                    example: 4471
                  kind:
                    type: string
                    enum:
                    - recognition
                    - award
                    - certificate
                    description: The detail API's own vocabulary, so the client keys
                      its copy off the same word on both surfaces. An automated Award
                      (anniversary, milestone, lifecycle) is a `certificate`.
                    example: recognition
                  title:
                    type: string
                    nullable: true
                    description: The award-card / award name, when there is one.
                    example: Above and Beyond
                  from:
                    type: string
                    nullable: true
                    description: Who gave it, or `null` for an anonymous give — render
                      "someone" from a `null`, never a name.
                    example: Priya Raman
                  points:
                    type: integer
                    example: 25
                  received_at:
                    type: string
                    format: date-time
                    example: '2026-08-18T09:02:11Z'
        navigation_items:
          type: array
          description: |
            The app's in-app navigation (its web sidebar tabs / mobile TabBar),
            in display order. **Only present when `include_navigation=true`.**

            **Role-aware — absence is the authorization signal.** Items the
            caller may not open are omitted, not disabled: e.g. Ideas'
            `review_queue` appears only for review-panel members (admins get no
            bypass) and its `campaigns` item disappears when the business turns
            campaigns off; Forms' `approvals` appears only for reviewers. Do not
            render an item the API didn't return.

            May be an empty array: the app exposes no sub-navigation, every tab
            was dropped for having no mobile route, the mobile single-child
            collapse folded the only item into `mobile_url`, or the app's tab
            builder raised (failures degrade to `[]`, never a 500).
          items:
            "$ref": "#/components/schemas/AppNavigationItem"
      required:
      - id
      - slug
      - name
      - type
      - category
      - enabled
      - url
    AppNavigationItem:
      type: object
      description: |
        One entry of an app's in-app navigation, as returned in
        `ConsolidatedApp.navigation_items` by
        `GET /api/v1/apps?include_navigation=true`.

        Section headers and dividers from the web sidebar are stripped, and an
        item with neither a `path` nor usable `actions` is dropped — so every
        entry you receive is something the user can actually open.
      properties:
        key:
          type: string
          description: |
            Stable machine key for the item, safe to switch on client-side
            (titles are display copy and may be re-worded). Keys are per-app;
            Ideas emits `dashboard`, `all_ideas`, `campaigns`, `review_queue`;
            Forms emits `my_submissions`, `approvals`; Wikis emits `dashboard`,
            `all_wikis`; Broadcast & Alerts emits `broadcast`, `alert`;
            Communications emits `dashboard`, `feed`, `mail`,
            `my_posts`; Training emits `my_learning`, `catalog`, `my_records`,
            `my_team`; Recognitions emits `dashboard`, `feed`,
            `my_recognition`, `programs`, `awards`, `leaderboard`, `team` (note
            `awards` is the Award Cycles surface — the key matches the web
            sidebar's own tab key); Company Store emits `dashboard`, `catalog`,
            `orders`, `balance`, `approvals` (note `balance` is the Points
            surface — again the web sidebar's own tab key); Safety Hub emits
            `safety_hub_*` keys and Service Desk `service_desk_*` keys
            (`service_desk_my_requests`, `service_desk_access_requests`,
            `service_desk_knowledge_base`, `service_desk_agent_dashboard`,
            `service_desk_tickets`, `service_desk_more`, plus
            `service_desk_problems` / `service_desk_changes` on `actions` or
            promoted to the top level) — namespaced because the native client
            dispatches every app's keys through one shared switch. Apps whose
            navigation comes from the generic sidebar builder use that app's own
            tab keys.
          example: review_queue
        title:
          type: string
          description: |
            Display label — never an identifier; branch on `key`. Service Desk's
            titles are in the language the client asks for (`locale`, else
            `Accept-Language`, else the caller's own language); other apps'
            titles are worded as the web sidebar shows them, in English today.
          example: Reviews
        icon:
          type: string
          description: Font Awesome class for the item (same convention as `ConsolidatedApp.icon.url`).
          example: fas fa-scale-balanced
        path:
          type: string
          nullable: true
          description: |
            Path to open for this item. For web callers this is the desktop path
            (e.g. `/apps/ideas/list`). For mobile clients, apps built from the
            web sidebar return the validated `/m/...` equivalent — a tab whose
            desktop path has no real `/m/` route is dropped from the array
            rather than returned with a dead link. Apps whose mobile client
            renders their tabs natively by `key` (Safety Hub, Service Desk)
            return the desktop path to mobile callers too, as the webview
            fallback. `null` on a `native: true` item; ABSENT on an item that
            only groups `actions` (Service Desk's `service_desk_more`).
          example: "/apps/ideas/review"
        native:
          type: boolean
          description: |
            Present (and always `true`) only for mobile callers on tabs the
            iOS/Android client renders with its OWN native screen — there is no
            webview route, so `path` is `null` and the client must dispatch on
            `key`. Applies to a fixed set of core-app tabs (Shifts'
            `my_shifts` / `my_availability`, Time & Attendance's
            `my_attendance`, Leave's `my_time_off`, Timesheets'
            `my_timesheets`). Absent on every other item.
          example: true
        count:
          type: integer
          description: |
            Live badge count for the item. Only emitted where the builder
            computes one — today that is the Forms `approvals` item, whose count
            matches exactly what `/forms/approvals` lists for this reviewer
            (whole-business `pending_review` for admin-tier, the manager's own
            reportees' `under_review` submissions for a manager). Treat as
            optional everywhere else.
          example: 3
        actions:
          type: array
          description: |
            Secondary actions nested under the item (the sidebar's per-tab
            dropdown). Dividers/headers are stripped, and for mobile callers any
            action without a valid `/m/` route is removed — so this array can be
            shorter than the web menu, or absent when nothing survived.
          items:
            type: object
            properties:
              key:
                type: string
                description: |
                  Stable machine key for the action, safe to switch on — present
                  only where the builder emits one. Service Desk's
                  `service_desk_more` carries `service_desk_problems`,
                  `service_desk_changes` and `service_desk_access_requests` here, so the
                  client can open each tile of its More sheet natively.
                example: service_desk_problems
              title:
                type: string
                example: New idea
              url:
                type: string
                description: Path to open, following the same rule as the parent item's
                  `path`.
                example: "/apps/ideas/new"
              icon:
                type: string
                example: fas fa-plus
    TrainingInfoRows:
      type: array
      description: The "Course Info" / "Path Info" card as ORDERED label/value pairs,
        in the order the web card renders them (Duration, Lessons, Difficulty, Credits,
        Certificate, Self-enroll), followed by the API-only Due and Version rows.
        The row set is tenant- and variant-dependent (an instructor-led course swaps
        the Duration/Lessons pair for Sessions available and adds Attendance), so
        a client renders what it is given rather than hardcoding fields. Blank values
        are omitted entirely; `Self-enroll` is always present and answers Yes / No
        / "No (off in Training settings)" — the EFFECTIVE state, matching the top-level
        `allow_self_enrollment` and the `cta`.
      items:
        type: object
        required:
        - label
        - value
        properties:
          label:
            type: string
            example: Difficulty
          value:
            type: string
            example: Intermediate
    TrainingReviewRow:
      type: object
      description: One approved review on a course/path detail.
      properties:
        id:
          type: integer
          example: 501
        rating:
          type: integer
          minimum: 1
          maximum: 5
          example: 5
        title:
          type: string
          nullable: true
          example: Clearest version I have seen
        content:
          type: string
          nullable: true
          example: I printed page 2 and taped it inside the tool cabinet.
        created_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-24T09:00:00Z'
        mine:
          type: boolean
          description: The caller's own review.
          example: false
        reviewer:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              description: The reviewer's user id.
              example: 41157
            name:
              type: string
              example: Priya Nair
            initials:
              type: string
              nullable: true
              example: PN
            job_title:
              type: string
              nullable: true
              example: Parts Counter
            avatar_url:
              type: string
              description: ABSOLUTE avatar URL. Never null for a real person — a user
                with no profile photo gets a generated ui-avatars.com initials tile
                — so a client can render it unconditionally. `initials` is provided
                too, for a client that prefers to draw its own tile rather than fetch
                one.
              example: https://ui-avatars.com/api/?name=Priya%20Nair&size=40&background=random
    TrainingSessionRow:
      type: object
      description: |-
        One instructor-led session. **Print `formatted_date` and `formatted_time`.** Everything else in the time block is raw material for sorting, arithmetic and calendar entries.

        An instructor-led session happens in a room, or a webinar scheduled by a host, in a specific PLACE — so every time here is anchored to the VENUE's zone, never the viewer's, and the web renders it the same way. A learner in another country sees the time the class actually starts, not their own clock.
      properties:
        id:
          type: integer
          example: 90
        title:
          type: string
          nullable: true
          example: Morning cohort
        session_type:
          type: string
          enum:
          - classroom
          - webinar
          - hybrid
          example: classroom
        formatted_date:
          type: string
          nullable: true
          description: The session's date, composed server-side in the VENUE's zone
            and ready to print — the same string the web session row renders. Pair
            it with `formatted_time`.
          example: Thu, Aug 20
        formatted_time:
          type: string
          nullable: true
          description: |-
            The session's time range WITH its zone abbreviation, composed server-side in the VENUE's zone and ready to print — the same string the web session row renders.

            PRINT THIS rather than formatting `starts_at` yourself. The natural mistake (`new Date(starts_at)` and a local format) renders in the DEVICE's zone and shows a learner in another country a time the session does not start at. There is nothing to get wrong here.
          example: 11:00 AM – 3:00 PM EDT
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: |-
            The instant, carrying the VENUE's UTC offset (not `Z`, and not the caller's offset). For sorting, countdowns and calendar entries; for display use `formatted_time`.

            Until 2026-09-16 this was serialized from the raw column, which renders in the ambient request zone — so it was documented as UTC and was in fact the CALLER's offset, differing per viewer while `timezone` beside it named the venue. The instant was always correct, so any client parsing it as an instant is unaffected; only naive string handling changes, and it changes from an unstable caller clock to the stable venue clock.
          example: '2026-08-20T11:00:00-04:00'
        ends_at:
          type: string
          format: date-time
          nullable: true
          description: As `starts_at` — the VENUE's offset.
          example: '2026-08-20T15:00:00-04:00'
        timezone:
          type: string
          nullable: true
          description: |-
            IANA identifier for the VENUE's zone (`America/New_York`, `Etc/UTC`), resolvable by `TimeZone(identifier:)`, `ZoneId.of` and `Intl.DateTimeFormat`. NOT needed for display — `formatted_time` already carries the zone. Needed when you want the zone's IDENTITY rather than one instant's offset: a calendar entry's `TZID`, or arithmetic that crosses a daylight-saving boundary. An offset cannot substitute, because the mapping is many-to-one — `-05:00` is Chicago in summer, New York in winter and Bogota all year.

            Normalized on the way out as of 2026-09-16. The stored value is an ActiveSupport zone NAME (the authoring form writes those deliberately), so this field previously returned strings like `International Date Line West` that no client zone API resolves — 10 of 14 sessions on one tenant.
          example: America/New_York
        timezone_label:
          type: string
          nullable: true
          description: Short zone abbr, or UTC±offset. NOW REDUNDANT — the same text
            closes `formatted_time`. Retained for compatibility; do not build on it.
            Useful only if you render the abbreviation in different styling from the
            time.
          example: EDT
        duration_minutes:
          type: integer
          nullable: true
          description: Derived from the two instants, so you need not subtract. Zone-independent.
          example: 240
        location:
          type: string
          nullable: true
          example: Training Room B
        meeting_link:
          type: string
          nullable: true
          example:
        instructor:
          type: object
          nullable: true
          properties:
            name:
              type: string
              example: Maria Lopez
            job_title:
              type: string
              nullable: true
              example: Regional Safety Trainer
        capacity:
          type: object
          properties:
            limit:
              type: integer
              nullable: true
              example: 12
            taken:
              type: integer
              example: 9
            seats_available:
              type: integer
              nullable: true
              example: 3
            full:
              type: boolean
              example: false
            waitlist_count:
              type: integer
              example: 0
        registration:
          type: object
          properties:
            open:
              type: boolean
              description: Scheduled and the deadline has not passed.
              example: true
            closes_at:
              type: string
              format: date-time
              nullable: true
              description: When registration closes — the session's own start when
                no explicit deadline was set. Carries the VENUE's UTC offset, like
                `starts_at`.
              example: '2026-08-19T11:00:00-04:00'
        my_registration:
          type: object
          nullable: true
          description: Present only when the caller's seat is on THIS session.
          properties:
            id:
              type: integer
              example: 771
            status:
              type: string
              example: registered
            waitlisted:
              type: boolean
              example: false
            waitlist_position:
              type: integer
              nullable: true
              example:
        cta_action:
          type: string
          description: 'The web row''s precedence: a seat you already hold wins, then
            a closed registration, then a full session (waitlist), then switching,
            then booking. `switch` appears only when the caller''s seat is CHANGEABLE
            (registered or waitlisted) — a session already attended or completed is
            never offered as something to switch away from, since that would discard
            earned attendance. `locked` — the request''s API token lacks write:training,
            so the row offers none of join_waitlist / switch / register (each a POST
            asking it); draw no button. A signed-in session never gets it.'
          enum:
          - registered
          - waitlisted
          - closed
          - join_waitlist
          - switch
          - register
          - locked
          example: register
    TrainingSeriesRow:
      type: object
      description: One recurring instructor-led class on a course, as the caller sees
        it — the web's "Recurring class" card. Its dates are still listed one by one
        in `sessions`; this is the one entry to join or leave (ISS-20261102-911-B4E0DF).
      properties:
        id:
          type: integer
          description: The series id the membership endpoints take.
          example: 12
        title:
          type: string
          example: Weekly safety huddle
        schedule_summary:
          type: string
          description: The repeat rule in words.
          example: Every week on Tuesday, 12 times
        next_session:
          type: object
          nullable: true
          description: The next date still to run, with the same venue-zone time block
            every session payload carries (print `formatted_date` / `formatted_time`).
            Null when no date is left.
          properties:
            id:
              type: integer
              example: 90
            formatted_date:
              type: string
              nullable: true
              example: Tue, Nov 4
            formatted_time:
              type: string
              nullable: true
              example: 9:00 AM – 10:00 AM EST
            starts_at:
              type: string
              format: date-time
              nullable: true
            ends_at:
              type: string
              format: date-time
              nullable: true
            timezone:
              type: string
              nullable: true
              example: America/New_York
            timezone_label:
              type: string
              nullable: true
              example: EST
            duration_minutes:
              type: integer
              nullable: true
              example: 60
        dates_left:
          type: integer
          description: Dates still to run.
          example: 8
        member:
          type: boolean
          description: The caller is in this class.
        next_seat_id:
          type: integer
          nullable: true
          description: The caller's seat (registration id) on `next_session`, or null.
            Offer "Can't make the next one" only when it is set; it is `DELETE /training/sessions/{next_session.id}/registration`,
            which keeps the caller in the class.
    TrainingSeriesMembershipResponse:
      type: object
      properties:
        series:
          "$ref": "#/components/schemas/TrainingSeriesRow"
        message:
          type: string
          description: The sentence to show — the web's.
          example: You're in Weekly safety huddle. It's on your calendar as one series.
        waitlisted:
          type: integer
          description: Dates the class was full on, where the caller is on the waitlist.
          example: 0
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
    TrainingLessonRow:
      type: object
      description: One row of a course's lesson outline. Byte-identical whether it
        arrives inlined on the course detail or from the Lessons tab endpoint — both
        serialize the same outline through one serializer, so the tab can never disagree
        with the detail it was opened from.
      properties:
        id:
          type: integer
          example: 91
        title:
          type: string
          example: Lockout/Tagout Procedure
        content_type:
          type: string
          enum:
          - text
          - video
          - document
          - quiz
          - scorm
          - partner_course
          - practice
          example: video
        type_label:
          type: string
          description: From the canonical TrainingLesson#content_type_label, so the
            app names a lesson exactly as the web page does (e.g. "SCORM", not "Scorm").
          example: Video
        provider:
          type: string
          nullable: true
          description: The vendor behind a `partner_course` lesson — the human-readable
            name the web and `/m/` lesson bodies print above the launch card (e.g.
            "Go1"), so a client can say WHOSE course was taken rather than only that
            it was a partner's. Null for every other `content_type`.
          example:
        type_icon:
          type: string
          example: fa-video
        duration_minutes:
          type: integer
          nullable: true
          description: 'The lesson''s time in minutes: the author''s estimate, or
            — for a text or quiz lesson nobody timed — worked out from its text (about
            200 words a minute, at least one) or its quiz (its time limit, else a
            minute a question an attempt is given). Null when there is neither. For
            a video this is not necessarily the video''s length — see `meta_line`.'
          example: 12
        meta_line:
          type: string
          nullable: true
          description: |-
            The lesson's one-line label, composed exactly as the web player's eyebrow and the `/m/` reader render it — "Text lesson · 5m read", "Video lesson · 0:48 watch", "PDF document · 10m", "SCORM module". An estimate is written as Training writes every length ("45m", "1h 40m"), a measured video as its player's clock. RENDER THIS instead of composing your own from `type_label` + `duration_minutes`: a native client drawing its own lesson header is trying to match the other two surfaces, and those two fields cannot reproduce them. `type_label` is the bare type ("Video", not "Video lesson"), and `duration_minutes` is the author's estimate while an uploaded video's REAL length is measured from the file — lesson 479 renders "Video lesson · 0:48 watch" where the raw fields compose to "Video · ~8 min".

            THE DURATION RULE IS PER TYPE and is deliberately server-side, because it is the part that has drifted: ffprobed-length-then-estimate suffixed "watch" for video, the estimate suffixed "read" for text, bare minutes for a document, and NO duration for `scorm` / `quiz` / `partner_course` — those render a launch card whose estimated time is a tile inside the card, not part of this line.

            The raw fields stay for clients that compose their own. Null only when the lesson's type and duration are both unresolvable.
          example: Video lesson · 0:48 watch
        required:
          type: boolean
          example: true
          description: Whether the lesson counts toward completing the course — the
            course's progress rule over this outline. On a course with no required
            lesson every playable lesson counts, so every row is true; an `unavailable`
            lesson is always false.
        unavailable:
          type: boolean
          example: false
          description: True when the COURSE cannot play this lesson right now, for
            any learner (a partner licence ended, a scenario was removed, Practice
            Hub is off). It is not optional — show it as unavailable, with `unavailable_reason`;
            it is left out of the progress count until it plays again. A lesson only
            the caller cannot take (no partner seat left for them) is NOT unavailable
            here.
        unavailable_reason:
          type: string
          nullable: true
          enum:
          - no_course
          - unpublished
          - no_license
          - no_portal
          - no_scenario
          - app_disabled
          - no_persona
          -
          example:
          description: 'Why the lesson is `unavailable`, else null. Learner-safe wording
            the web uses: no_course / unpublished / no_scenario → "content removed";
            no_license → "licence ended"; no_portal → "provider not connected"; app_disabled
            → "practice not enabled"; no_persona → "not ready yet".'
        completed:
          type: boolean
          example: false
        current:
          type: boolean
          description: 'The lesson the CTA resumes into: the first lesson not yet
            completed, else the first. At most one row carries this. While the course
            is in progress it is always the same lesson as the detail''s `cta.lesson_id`,
            so a client can open the player from either. On a finished course `cta`
            names no lesson and this row still carries the flag (an optional lesson
            left undone, else the first lesson) — it is not work left to do; the detail''s
            `secondary_cta.lesson_id`, when set, is where Review course opens. Never
            true for a caller with no enrollment.'
          example: true
        locked:
          type: boolean
          description: 'True only when the caller holds no enrollment — lessons are
            not sequentially gated within a course. A locked row is the outline only:
            `web_view_url` is null, and the lesson''s content endpoint refuses the
            caller.'
          example: false
        web_view_url:
          type: string
          nullable: true
          description: |-
            Absolute URL of the BARE mobile render of this lesson (`/m/apps/training/courses/:course_id/lessons/:id`) — the whole ONLINE story for taking a self-paced lesson: load it in a native WebView and the lesson displays with no header, no bottom tab bar, no prev/next and no Mark Complete, because the native screen draws its own and calls `POST …/lessons/{lesson_id}/progress` for the completion.

            ALWAYS CARRIES `?embed=1`, and that param is the ONLY thing that strips the chrome — load this url VERBATIM. Unlike the Wikis reader there is no User-Agent fallback here, deliberately: the existing `/m/` Training pages must keep their chrome for anyone browsing them in a mobile browser, so the bare layout is strictly opt-in. Drop the param and you get the full mobile page inside your native screen.

            Renders every self-paced type: text, video (uploaded or embedded) and document inline; a `scorm` or `partner_course` lesson shows a launch card whose button opens the provider's runtime full-screen and returns here on exit. Null when the serializer had no request context, and on a `locked` row (the caller holds no enrollment, and the page would refuse them).

            A COURSEWARE LESSON HAS A NATIVE CARD TOO — `GET …/lessons/{lesson_id}/scorm` — and it is the sibling of the quiz card described below, not a lesser surface. It returns the module's status, score, resume sentence/percent, bookmark, launch count, package standard, mastery score, time limit and a resolved `cta`, so a client can draw the whole Status / Score / Estimated time / Resume panel natively; `POST …/lessons/{lesson_id}/scorm/restart` is the "Start over". Only the SCORM/xAPI/cmi5/AICC RUNTIME itself has to stay a WebView (inherent to those standards) — the card around it does not. This paragraph exists because it was missing: the quiz pointer below was here and this one was not, so ISS-20260910-827-69F8A8 was filed asking for courseware status to be added to this very row by an engineer who read the row serializer, found nothing, and never saw the dedicated endpoint next door. Do NOT expect these fields on the row — they are one lesson-addressed call away, deliberately, for the same reason the quiz card is not inlined here.

            A `quiz` LESSON SHOWS A QUIZ CARD, AND ITS BUTTON IS AN INTERCEPTION POINT. The card renders the question count, passing bar, attempt tallies and the learner's standing result, and its Start/Retake button is a plain GET link to `/m/apps/training/courses/{course_id}/lessons/{lesson_id}/quiz/launch`. A native client that wants to run the assessment itself should MATCH THAT PATH in its WebView, cancel the navigation, and drive the quiz endpoints instead — `GET …/lessons/{lesson_id}/quiz` for the same card state, then `POST …/quiz/attempts` and the rest. Reload this url when the native screen closes: passing writes the lesson completion and moves course progress, so the card underneath is stale until you do.

            A client that does NOT intercept needs no special handling — the link is a real route that starts or resumes the attempt and lands the learner in the responsive web player, so leaving it alone is a working flow rather than a dead button. It is a GET for exactly this reason: a POST form is not reliably visible to Android's `shouldOverrideUrlLoading`.
          example: https://officechat.workforce.mangoapps.com/m/apps/training/courses/37/lessons/91?embed=1
    TrainingPathStepRow:
      type: object
      description: |-
        One step of a learning path with its member courses. Identical whether it arrives inlined on the path detail or from the Steps tab endpoint.

        `courses` is ordered by the author's `sort_order` with the join row's id breaking ties: two member courses may legitimately share a sort_order, and without the tiebreak the same step came back in different orders depending on how the association had been preloaded.
      properties:
        id:
          type: integer
          example: 12
        position:
          type: integer
          description: 1-based.
          example: 2
        name:
          type: string
          nullable: true
          example: Core compliance
        courses_count:
          type: integer
          example: 3
        rule:
          type: string
          description: The step's completion rule chip.
          example: All required + 1 of the optional
        complete:
          type: boolean
          example: false
        locked:
          type: boolean
          description: Sequential path AND enrolled caller AND after the first incomplete
            step — all three, so a learner browsing a path they have not joined never
            sees a padlock.
          example: false
        completion_percent:
          type: integer
          description: 0..100 fill for the step's pill — the fraction of the step's
            REQUIRED weight satisfied. Counts COURSES not lessons
          so it is instructor-led-safe.:
          example: 67
        unlocks_after_step:
          type: integer
          nullable: true
          description: Present only when locked — the 1-based step that must finish
            first.
          example:
        courses:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 60
              title:
                type: string
                example: Benefits & Policies
              required:
                type: boolean
                example: true
              delivery_label:
                type: string
                enum:
                - Self-paced
                - Instructor-Led
                - Learning Path
                example: Self-paced
              progress_percentage:
                type: integer
                example: 100
              status:
                type: string
                enum:
                - not_started
                - in_progress
                - completed
                example: completed
              cta_action:
                type: string
                enum:
                - locked
                - review
                - resume
                - start
                - enroll_required
                example: review
    TrainingSubjectBlock:
      type: object
      description: The course or learning path a secondary screen belongs to — a reviews
        or Q&A list, a Lessons or Steps tab — so the client can title the screen without
        a second call. Every one of those endpoints emits it from the one helper (Api::V1::Training::SubjectPayload),
        the same one behind the `subject` block on the My Training cards.
      properties:
        id:
          type: integer
          example: 37
        title:
          type: string
          example: Workplace Safety Fundamentals
        type:
          type: string
          enum:
          - course
          - path
          example: course
        delivery_label:
          type: string
          enum:
          - Self-paced
          - Instructor-Led
          - Learning Path
          example: Self-paced
        category:
          type: object
          nullable: true
          properties:
            name:
              type: string
              example: Compliance
            color:
              type: string
              nullable: true
              example:
            icon:
              type: string
              nullable: true
              example:
    TrainingAnswerRow:
      type: object
      description: One answer inside a Q&A thread. `instructor` drives the INSTRUCTOR
        badge and is true only for a user holding the COURSE's Instructor role (directly
        or through a group) — a learning path has no per-path role, so it is always
        false there, matching the web badge exactly.
      properties:
        id:
          type: integer
          example: 23
        body:
          type: string
          example: Yes. There is no time threshold — if a guard comes off, the machine
            gets locked out.
        votes_count:
          type: integer
          description: Upvotes ("helpful") on this answer.
          example: 12
        is_best_answer:
          type: boolean
          description: At most one per question.
          example: true
        created_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-15T05:54:02Z'
        mine:
          type: boolean
          description: The caller wrote this answer.
          example: false
        my_vote:
          type: boolean
          description: The caller has upvoted it.
          example: false
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              description: The answerer's user id.
              example: 1
            name:
              type: string
              example: Maria Lopez
            initials:
              type: string
              nullable: true
              example: ML
            job_title:
              type: string
              nullable: true
              example: Regional Safety Trainer
            avatar_url:
              type: string
              description: ABSOLUTE avatar URL. Never null for a real person — a user
                with no profile photo gets a generated ui-avatars.com initials tile
                — so a client can render it unconditionally. `initials` is provided
                too, for a client that prefers to draw its own tile rather than fetch
                one.
              example: https://ui-avatars.com/api/?name=Priya%20Nair&size=40&background=random
            instructor:
              type: boolean
              example: true
    TrainingQuestionRow:
      type: object
      description: |-
        One question thread — the question plus its answers, mirroring one card of the web Q&A tab.
        `status` is what the client renders the waiting state from: an `unanswered` question shows the "waiting on your instructor" strip instead of an answer block.
        `best_answer` is a CONVENIENCE POINTER, not a second copy — it is the same object already present in `answers` (which is ordered best-answer first, then most upvoted, the web's exact sort), so a client with room for one answer reads it directly. It is null until someone marks a best answer, and a question can be `answered` with no best answer.
      properties:
        id:
          type: integer
          example: 30
        body:
          type: string
          example: Do I need lockout/tagout for a two-minute belt change?
        status:
          type: string
          enum:
          - unanswered
          - answered
          example: answered
        votes_count:
          type: integer
          description: Upvotes ("helpful") on the question.
          example: 17
        answers_count:
          type: integer
          example: 2
        created_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-15T05:54:02Z'
        mine:
          type: boolean
          description: The caller asked this.
          example: true
        my_vote:
          type: boolean
          description: The caller has upvoted it.
          example: false
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              description: The asker's user id.
              example: 41157
            name:
              type: string
              example: Priya Nair
            initials:
              type: string
              nullable: true
              example: PN
            job_title:
              type: string
              nullable: true
              example: Parts Counter
            avatar_url:
              type: string
              description: ABSOLUTE avatar URL. Never null for a real person — a user
                with no profile photo gets a generated ui-avatars.com initials tile
                — so a client can render it unconditionally. `initials` is provided
                too, for a client that prefers to draw its own tile rather than fetch
                one.
              example: https://ui-avatars.com/api/?name=Priya%20Nair&size=40&background=random
        best_answer:
          allOf:
          - "$ref": "#/components/schemas/TrainingAnswerRow"
          nullable: true
        answers:
          type: array
          items:
            "$ref": "#/components/schemas/TrainingAnswerRow"
    TrainingFeedbackPageMeta:
      type: object
      description: Pagination over the list (build_pagination_meta).
      properties:
        total_count:
          type: integer
          example: 25
        total_pages:
          type: integer
          example: 2
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          example: true
        has_prev_page:
          type: boolean
          example: false
    TrainingReviewsPage:
      type: object
      description: The full Ratings & Reviews list for a course or a learning path
        — one payload, served by both nested routes.
      required:
      - subject
      - rating
      - reviews
      - meta
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        rating:
          type: object
          description: The histogram header. APPROVED reviews only, and identical
            to the `rating` block on the course/path detail (both read HasReviews).
          properties:
            average:
              type: number
              nullable: true
              description: null when there are no approved reviews.
              example: 3.5
            count:
              type: integer
              example: 2
            distribution:
              type: object
              description: Star -> count, for the 5..1 histogram. SPARSE — a star
                with no reviews is ABSENT, not zero, so treat a missing key as 0.
                (Same shape the detail endpoints return.)
              additionalProperties:
                type: integer
              example:
                '5': 1
                '2': 1
        active_sort:
          type: string
          description: The sort actually applied — an unrecognised `sort` falls back
            to `recent`. `helpful` is deliberately NOT a sort here (the Helpful affordance
            is admin-only on the web), so `sort=helpful` is one of the unrecognised
            values and comes back as `recent`.
          enum:
          - recent
          - lowest
          example: recent
        reviews:
          type: array
          description: The requested page. The caller's OWN review is included here
            as well as in `my_review` (matching the detail endpoints); `mine` flags
            it.
          items:
            "$ref": "#/components/schemas/TrainingReviewRow"
        my_review:
          allOf:
          - "$ref": "#/components/schemas/TrainingReviewRow"
          nullable: true
          description: The caller's own review whether or not it falls on this page
            — the sheet's CTA reads "Edit your review" vs "Rate this course" off it.
            Null when they have not reviewed. Unlike `reviews`, this is NOT filtered
            to approved, so an author still sees a review awaiting moderation.
        can_review:
          type: boolean
          description: Enrolled AND has not already reviewed (TrainingReview.user_can_review?
            — the same rule the web gate applies). False once `my_review` is set.
          example: true
        meta:
          "$ref": "#/components/schemas/TrainingFeedbackPageMeta"
    TrainingQuestionsPage:
      type: object
      description: The Q&A thread list for a course or a learning path — one payload,
        served by both nested routes.
      required:
      - subject
      - counts
      - questions
      - meta
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        active_filter:
          type: string
          description: The filter actually applied — an unrecognised `filter` falls
            back to `all`.
          enum:
          - all
          - unanswered
          - top
          example: all
        counts:
          type: object
          description: FILTER-INDEPENDENT tallies, so all three pills can be badged
            from one request. `answered` is derived (`total - unanswered`), because
            the two statuses partition the set.
          properties:
            total:
              type: integer
              example: 25
            answered:
              type: integer
              example: 9
            unanswered:
              type: integer
              example: 16
        questions:
          type: array
          items:
            "$ref": "#/components/schemas/TrainingQuestionRow"
        permissions:
          type: object
          description: What this caller may do from the screen. Asking, answering
            and upvoting carry no per-owner gate on the web either, so for a signed-in
            caller who got a 200 they are true; each is a POST asking write:training,
            so an API token without it gets false for all of them. (Upvoting a row
            of your own is still refused per row, 422.)
          properties:
            can_ask:
              type: boolean
              example: true
            can_answer:
              type: boolean
              example: true
            can_vote:
              type: boolean
              example: true
            can_mark_best:
              type: boolean
              description: 'May the caller mark a best answer on this owner — the
                ONE Q&A action with a per-owner permission (a Training admin, the
                COURSE''s Instructor, or a learning path''s creator). Render the Mark-best
                affordance from THIS flag: `POST /training/qa/answers/{id}/mark_best`
                exists for every caller and answers 403 `forbidden` for a learner,
                so keying the button on the endpoint''s existence shows it to everyone.'
              example: false
        meta:
          "$ref": "#/components/schemas/TrainingFeedbackPageMeta"
    TrainingTeamStats:
      type: object
      description: A team's headline metrics, counted off ONE relation so they cannot
        disagree with each other. `.current` enrollments only, so a learner who retook
        a course is counted once.
      properties:
        team_size:
          type: integer
          description: Headcount in scope (the TRUE total
          not the page size).:
          example: 142
        total_enrollments:
          type: integer
          example: 119
        completed:
          type: integer
          example: 39
        in_progress:
          type: integer
          example: 31
        overdue:
          type: integer
          example: 17
        not_started:
          type: integer
          example: 48
        completion_rate:
          type: number
          description: Percent, 1dp. 0 (never null) for a team with no enrollments.
          example: 32.8
        avg_progress:
          type: number
          description: Mean progress percentage across current enrollments, 1dp. A
            NUMBER, not a string — Postgres AVG returns a BigDecimal and is cast before
            encoding.
          example: 42.6
        overdue_paths:
          type: integer
          description: The team's current learning paths past their due date — counted
            apart from `overdue`, which is course enrollments only. The Overdue tab
            lists the first page of them in `overdue_paths`.
          example: 2
    TrainingTeamMemberRow:
      type: object
      description: 'One learner card on the roster. Counts ship raw so the client
        owns the presentation: the progress bar is `done / total`, the caption is
        "N of M complete", and both the bar colour and the card''s alert border key
        off `overdue > 0`.'
      properties:
        id:
          type: integer
          example: 41157
        name:
          type: string
          example: Priya Raman
        initials:
          type: string
          nullable: true
          example: PR
        title:
          type: string
          nullable: true
          description: Job title in this business.
          example: Service Advisor
        avatar_url:
          type: string
          description: ABSOLUTE avatar URL; never null (falls back to a generated
            initials tile).
          example: https://ui-avatars.com/api/?name=Priya%20Raman&size=40&background=random
        total:
          type: integer
          description: Current enrollments.
          example: 7
        done:
          type: integer
          example: 5
        overdue:
          type: integer
          example: 2
    TrainingChecklistPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
        avatar_url:
          type: string
          format: uri
          nullable: true
    TrainingChecklistSignoff:
      type: object
      properties:
        id:
          type: integer
        item_key:
          type: string
        result:
          type: string
          enum:
          - met
          - unmet
          - not_applicable
        result_label:
          type: string
          example: Met
        passing:
          type: boolean
          description: Met and N/A count toward completing the lesson.
        validation_method:
          type: string
          nullable: true
        comment:
          type: string
          nullable: true
        signer_name:
          type: string
        signer_title:
          type: string
          nullable: true
        signed_at:
          type: string
          format: date-time
        can_withdraw:
          type: boolean
          description: The caller may withdraw it (POST …/withdraw) now — the record
            is still open and the token holds `destructive:training`.
    TrainingChecklistAttempt:
      type: object
      properties:
        enrollment_id:
          type: integer
        lesson:
          type: object
          properties:
            id:
              type: integer
            title:
              type: string
        course:
          type: object
          properties:
            id:
              type: integer
            title:
              type: string
              nullable: true
        learner:
          "$ref": "#/components/schemas/TrainingChecklistPerson"
        progress:
          type: object
          properties:
            total:
              type: integer
            passing:
              type: integer
            signed:
              type: integer
            complete:
              type: boolean
        can_sign:
          type: boolean
          description: The caller signs this learner's items now, and the token may
            write.
        sign_refusal:
          type: string
          nullable: true
          description: Why nothing can be signed now; null when it can.
        validation_methods:
          type: array
          items:
            type: string
        items:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              number:
                type: integer
                nullable: true
              section:
                type: string
                nullable: true
              text:
                type: string
              guidance:
                type: string
                nullable: true
              results:
                type: array
                items:
                  type: object
                  properties:
                    value:
                      type: string
                    label:
                      type: string
              comment_required:
                type: boolean
              can_sign:
                type: boolean
              signoff:
                allOf:
                - "$ref": "#/components/schemas/TrainingChecklistSignoff"
                nullable: true
              history_count:
                type: integer
                description: Earlier sign-offs of this item (superseded or withdrawn),
                  kept in its history.
    TrainingTeamPathEnrollmentRow:
      type: object
      description: 'One learning-path row on My Team: the dashboard''s `overdue_paths`
        (with `learner`) and the drill-in''s `paths`. `can_remind` is the web row''s
        own test — still owed, and dated — on a token holding `write:training`, and
        POST /training/my_team/{id}/remind with `path_enrollment_id` sends it.'
      properties:
        path_enrollment_id:
          type: integer
        learning_path_id:
          type: integer
          nullable: true
        title:
          type: string
          nullable: true
        status:
          type: string
          enum:
          - enrolled
          - in_progress
          - completed
          - cancelled
        progress:
          type: integer
          minimum: 0
          maximum: 100
        overdue:
          type: boolean
        days_overdue:
          type: integer
          nullable: true
          description: Null unless overdue.
        due_date:
          type: string
          format: date
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        can_remind:
          type: boolean
        learner:
          type: object
          nullable: true
          description: Present on the dashboard's rows only.
          properties:
            id:
              type: integer
            name:
              type: string
            initials:
              type: string
              nullable: true
            title:
              type: string
              nullable: true
            avatar_url:
              type: string
              format: uri
              nullable: true
    TrainingTeamEnrollmentRow:
      type: object
      description: |-
        One enrollment row inside My Team. Raw values, no pre-composed sentences — the client builds "Due 12 Aug · 40% done" / "3 days overdue" itself, so the copy stays translatable.
        `learner` is present only where the row appears OUT of a learner's context (the dashboard's overdue list, which spans the team); the member drill-in omits it because its header already names the person.
      properties:
        enrollment_id:
          type: integer
          description: Pass this to the remind endpoint.
          example: 22989
        course_id:
          type: integer
          nullable: true
          example: 128
        title:
          type: string
          nullable: true
          example: Forklift Operator Certification
        course_archived:
          type: boolean
          description: true when the row's course has been archived. The member drill-in's
            `open` list keeps such a row so it can be removed, but the learner no
            longer sees it and `bucket_counts` leaves it out; the web and phone drill-ins
            label it "Course archived" in place of its status.
          example: false
        status:
          type: string
          example: enrolled
        progress:
          type: integer
          description: Percent complete
          0..100.:
          example: 40
        overdue:
          type: boolean
          example: true
        days_overdue:
          type: integer
          nullable: true
          description: null when not overdue — 0 would read as "due today".
          example: 25
        due_date:
          type: string
          format: date-time
          nullable: true
          example: '2026-08-06T00:00:00Z'
        completed_at:
          type: string
          format: date-time
          nullable: true
          example:
        learner:
          type: object
          nullable: true
          description: Present on the dashboard's overdue rows only.
          properties:
            id:
              type: integer
              example: 1257
            name:
              type: string
              example: Melanie Kirkwood
            initials:
              type: string
              nullable: true
              example: MK
            title:
              type: string
              nullable: true
              example: Shift Lead
            avatar_url:
              type: string
              example: https://ui-avatars.com/api/?name=Melanie%20Kirkwood&size=40&background=random
    TrainingMyTeamDashboard:
      type: object
      description: The My Team dashboard for ONE tab. `members`, `overdue` and `completions`
        always all appear and only the tab named by `active_tab` carries rows — stable
        key types for a generated client, at the cost of two empty arrays. `stats`
        and `pills` are tab-independent; `meta` paginates the ACTIVE tab.
      required:
      - scope
      - active_tab
      - stats
      - pills
      - members
      - overdue
      - overdue_paths
      - completions
      - meta
      properties:
        active_tab:
          type: string
          description: The tab actually applied — an unrecognised `tab` falls back
            to `people`. Tells the client which of the three list keys to read.
          enum:
          - people
          - overdue
          - done
          example: people
        scope:
          type: object
          description: 'Who the caller is looking at. `total` and `per_page` ship
            separately from the page so the client can compose its own "Showing N
            of M" caption rather than parse a sentence, and `admin_view` tells it
            whether to say "Employees" or "Team". `key` + `available` drive the Team
            Scope segmented control: render it only when `available` holds more than
            one value, and select `key`.'
          properties:
            key:
              type: string
              description: The scope actually applied. An admin defaults to `all_employees`
                and may narrow to `direct_reports`; a manager is `direct_reports`
                unless they ask for `whole_line` (their direct reports and everyone
                below them) and have a line below their reports.
              enum:
              - direct_reports
              - all_employees
              - whole_line
              example: all_employees
            available:
              type: array
              description: What THIS caller may request via `?scope=`. `direct_reports`
                and `all_employees` for a Training admin, `["direct_reports"]` alone
                for a people-manager, plus `whole_line` for anyone whose reports have
                reports — the role is a ceiling, so a manager requesting `all_employees`
                is still served their own team (see `reach_label`).
              items:
                type: string
                enum:
                - direct_reports
                - all_employees
                - whole_line
              example:
              - direct_reports
              - all_employees
            admin_view:
              type: boolean
              description: true = these rows are every active member of the business.
                Derived from the APPLIED scope rather than the role, so an admin who
                has narrowed to `direct_reports` reads false and labels the roster
                "Team".
              example: true
            reach_label:
              type: string
              nullable: true
              description: 'Set when the `direct_reports` rows also hold the people
                of the places this caller looks after — a Training role limited to
                specific places, or a location contact role the business chose in
                Training settings: the people based at those places (or beneath them)
                and anyone posted there today. The words name the places ("Platform
                B"); null for everyone else. Show it beside "Team" so the roster is
                not read as direct reports alone.'
              example: Platform B
            total:
              type: integer
              example: 142
            per_page:
              type: integer
              example: 20
            capped:
              type: boolean
              description: true when the roster is larger than one page.
              example: true
        stats:
          "$ref": "#/components/schemas/TrainingTeamStats"
        actions:
          "$ref": "#/components/schemas/TrainingManagerBrief"
        pills:
          type: array
          description: The three filter pills with the counts that badge them. Keys
            are stable (`people`, `overdue`, `done`).
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - people
                - overdue
                - done
                example: overdue
              count:
                type: integer
                example: 17
                description: 'On `overdue`: the overdue COURSE enrollments.'
              path_count:
                type: integer
                example: 2
                description: On `overdue` only — the team's overdue learning paths,
                  listed in `overdue_paths`.
        members:
          type: array
          description: The roster page, ordered by first then last name. Populated
            only when `active_tab` is `people`; otherwise an empty array.
          items:
            "$ref": "#/components/schemas/TrainingTeamMemberRow"
        overdue:
          type: array
          description: Overdue rows across the WHOLE team (not just the roster page
            — the pill counts the team, so its list must too), soonest-due first and
            PAGINATED. Each row names its learner. Populated only when `active_tab`
            is `overdue`.
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        overdue_paths:
          type: array
          description: The team's overdue LEARNING PATHS — the web Overdue page's
            second list — soonest-due first, as many as a page shows (`stats.overdue_paths`
            is their total; open a member's drill-in for the rest). Each row names
            its learner. Populated only when `active_tab` is `overdue`.
          items:
            "$ref": "#/components/schemas/TrainingTeamPathEnrollmentRow"
        completions:
          type: array
          description: Most recent completions across the whole team, PAGINATED. Populated
            only when `active_tab` is `done`.
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        meta:
          allOf:
          - "$ref": "#/components/schemas/TrainingFeedbackPageMeta"
          description: Pagination for the ACTIVE tab — so `total_count` is the roster
            size on `people`, the overdue count on `overdue`, and the completed count
            on `done`.
    TrainingManagerBrief:
      type: object
      description: The manager's action brief for the same team as `scope` — the work
        waiting on them, counted before pagination (Training::ManagerBrief). Without
        `brief_kind`, `rows` is the top five actions across every kind the caller
        has not hidden, and `hidden` the ones passed over to reach them. With `brief_kind`,
        `rows` is one 20-row page of that kind (`brief_page`), each row carrying its
        own `hidden` flag.
      required:
      - counts
      - total
      - kind
      - page
      - has_more
      - rows
      - hidden
      properties:
        counts:
          type: object
          description: Open actions per kind, for the whole team.
          properties:
            signoffs:
              type: integer
              description: Checklists to sign.
              example: 2
            follow_up:
              type: integer
              description: Assessment follow-up.
              example: 1
            renewals:
              type: integer
              description: Credentials to renew.
              example: 0
            overdue:
              type: integer
              description: Overdue training.
              example: 4
        total:
          type: integer
          description: The sum of `counts`.
          example: 7
        kind:
          type: string
          nullable: true
          description: The `brief_kind` applied; null for the top-five view (an unrecognised
            `brief_kind` is ignored).
          enum:
          - signoffs
          - follow_up
          - renewals
          - overdue
          -
          example:
        page:
          type: integer
          minimum: 1
          example: 1
        has_more:
          type: boolean
          nullable: true
          description: With a `kind`, true when another 20-row page follows; null
            in the top-five view.
          example:
        rows:
          type: array
          items:
            "$ref": "#/components/schemas/TrainingManagerBriefRow"
        hidden:
          type: array
          description: 'Top-five view only — the actions the caller hid that were
            passed over (always `hidden: true`); empty with a `kind`.'
          items:
            "$ref": "#/components/schemas/TrainingManagerBriefRow"
    TrainingManagerBriefRow:
      type: object
      required:
      - key
      - kind
      - learner
      - user_id
      - source_id
      - url
      - action_label
      - signature
      - hidden
      properties:
        key:
          type: string
          description: Stable id of the action (`<kind>_<source_id>`, plus the lesson
            id on a sign-off).
          example: overdue_812
        kind:
          type: string
          enum:
          - signoffs
          - follow_up
          - renewals
          - overdue
          example: overdue
        title:
          type: string
          nullable: true
          description: The course, quiz or credential the action is about.
          example: Forklift Safety
        learner:
          type: string
          description: The learner's name ("Unnamed user" when they have none).
          example: Dana Ruiz
        user_id:
          type: integer
          example: 77
        source_id:
          type: integer
          description: The enrollment, assessment attempt or certification the action
            is about.
          example: 812
        url:
          type: string
          description: The web page that takes the action (a path on the tenant host);
            it opens behind that page's own permission check.
          example: "/apps/training/manager/team/77"
        action_label:
          type: string
          enum:
          - Open learner
          - Review checklist
          - Review follow-up
          example: Open learner
        signature:
          type: string
          description: Changes when the action changes; a hidden action comes back
            once its signature no longer matches.
        hidden:
          type: boolean
          description: True when the caller hid this action and it has not changed
            since.
          example: false
    TrainingMyTeamMember:
      type: object
      required:
      - member
      - departments
      - counts
      - bucket_counts
      - open
      - completed
      - paths
      - certificates
      properties:
        member:
          "$ref": "#/components/schemas/TrainingTeamMemberRow"
        departments:
          type: array
          description: Every department the learner belongs to in this organization,
            in the order the web and phone drill-ins list them under the job title.
            Empty when they belong to none, or where the organization's field settings
            keep Department from the caller.
          items:
            type: string
          example:
          - Design
          - Marketing
        counts:
          type: object
          description: 'LEGACY, may overlap — unchanged for shipped apps. By raw status:
            `assigned` is not begun, `in_progress` started; `overdue` counts outstanding
            rows past their due date that are ALSO in `assigned` or `in_progress`.
            New clients read `bucket_counts`.'
          properties:
            assigned:
              type: integer
              description: Legacy — not begun (raw status enrolled); may overlap overdue.
              example: 2
            in_progress:
              type: integer
              description: Legacy — may overlap overdue.
              example: 3
            completed:
              type: integer
              example: 5
            overdue:
              type: integer
              example: 1
        bucket_counts:
          type: object
          description: 'The PARTITION of what the learner has outstanding, as the
            web and phone drill-ins and the learner''s own My Learning show it (Training::LearningStatus):
            each open course or path is in exactly one bucket, overdue first.'
          required:
          - not_started
          - in_progress
          - overdue
          properties:
            not_started:
              type: integer
              example: 2
            in_progress:
              type: integer
              example: 2
            overdue:
              type: integer
              example: 1
        open:
          type: array
          description: 'Everything outstanding, newest first. `learner` is omitted
            (the header names them). A row whose course has since been archived is
            listed too, with `course_archived: true`, so it can be removed; it is
            not counted in `bucket_counts`.'
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        completed:
          type: array
          description: The completed history, newest first.
          items:
            "$ref": "#/components/schemas/TrainingTeamEnrollmentRow"
        paths:
          type: array
          description: Their learning paths (not cancelled), newest first — already
            counted in `counts` / `bucket_counts`. Remind one with `path_enrollment_id`.
          items:
            "$ref": "#/components/schemas/TrainingTeamPathEnrollmentRow"
        certificates:
          type: array
          description: Issued certificates, newest first, capped at 12.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 64
              title:
                type: string
                nullable: true
                example: Workplace Safety Fundamentals
              issued_at:
                type: string
                format: date-time
                nullable: true
                example: '2026-07-24T09:00:00Z'
    TrainingReviewCreateRequest:
      type: object
      description: |-
        Body of a review POST (a course or a learning path — same shape for both).
        Every field must be a SINGLE SCALAR. A collection shape (`rating[]=5`, `title[a]=x`) is read as ABSENT rather than coerced — so a non-scalar `rating` fails its presence validation into the normal per-field 422, and a non-scalar `title`/`content` is stored as null instead of as the collection's own string form.
      required:
      - rating
      properties:
        rating:
          type: integer
          description: Whole stars, 1..5. Missing or out of range answers 422 with
            a per-field error.
          minimum: 1
          maximum: 5
          example: 5
        title:
          type: string
          nullable: true
          maxLength: 255
          example: Genuinely useful
        content:
          type: string
          nullable: true
          maxLength: 5000
          example: The lockout/tagout walkthrough matched our floor exactly.
    TrainingReviewUpdateRequest:
      type: object
      description: |-
        Body of a review PATCH. Every field is OPTIONAL and applied only when the key is PRESENT, so a client can change the stars without resending prose it is not touching — omitting `title` leaves the stored title alone, whereas sending it blank clears it.
        At least one of the three must be present; an empty body is 422 `nothing_to_update` rather than a silent no-op.
        Same single-scalar rule as the create request: a collection shape is read as ABSENT, so `title[]=x` clears the title rather than storing the collection's own string form.
      minProperties: 1
      properties:
        rating:
          type: integer
          description: Whole stars, 1..5. Sending it blank or out of range is 422
            with a per-field error — it does NOT fall back to the stored value.
          minimum: 1
          maximum: 5
          example: 4
        title:
          type: string
          nullable: true
          maxLength: 255
          description: Send blank to clear.
          example: Better on a second pass
        content:
          type: string
          nullable: true
          maxLength: 5000
          description: Send blank to clear.
          example: Revisited after the refresher — the LOTO section is the strongest
            part.
    TrainingReviewWriteResult:
      type: object
      description: |-
        The payload of EITHER review write — the 201 from a POST or the 200 from a PATCH. One shape, because a client refreshes the same summary block after either. It carries the RECOMPUTED `rating` histogram (never the pre-write one), which is the whole reason to read this response instead of patching a cached row: editing the stars moves the histogram.
        (Named `TrainingReviewCreated` until the edit endpoint shipped; renamed because the shape was never create-specific.)
      required:
      - subject
      - rating
      - review
      - can_review
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        rating:
          type: object
          description: The histogram header including the review just posted — identical
            in shape to the `rating` block on the reviews list and the course/path
            detail (all three read HasReviews#rating_summary).
          properties:
            average:
              type: number
              nullable: true
              example: 4.7
            count:
              type: integer
              example: 3
            distribution:
              type: object
              description: Star -> count. SPARSE — treat a missing key as 0.
              additionalProperties:
                type: integer
              example:
                '5': 2
                '4': 1
        review:
          "$ref": "#/components/schemas/TrainingReviewRow"
        can_review:
          type: boolean
          description: Always false here — the caller has just used their one review.
            Same key the reviews GET returns, so the CTA flips to "Edit your review"
            off this response.
          example: false
    TrainingQaPostRequest:
      type: object
      description: 'Body of the Q&A POST. `question_id` is what selects between the
        endpoint''s two behaviours: absent asks a new question, present posts an answer
        (a "reply") to that question.'
      required:
      - body
      properties:
        body:
          type: string
          description: The question or answer text. Must be a SINGLE SCALAR — whitespace-only
            AND any collection shape (`body[]=x`, `body[a]=x`) are rejected 422 `body_required`,
            so a malformed body can never be stored as the collection's own string
            form. Capped at 2000 characters for a question and 5000 for an answer
            (model validations — an over-long body answers 422 with a per-field error).
          example: Do I need lockout/tagout for a two-minute belt change?
        question_id:
          type: integer
          nullable: true
          description: |-
            Omit to ASK. Pass a question id to REPLY to it. Must name a question on THIS course / learning path — anything else is 422 `question_not_found`, never a misfiled answer. There is no reply-to-a-reply: an answer has no parent answer.
            Must be a SINGLE SCALAR. A collection shape (`question_id[]=30`) is a malformed reply TARGET and answers 422 `question_not_found` — it does NOT degrade into asking a new question, which would file the text somewhere the caller never asked for.
          example: 30
    TrainingQaThread:
      type: object
      description: ONE Q&A thread and its context — the payload of every Q&A WRITE
        (the ask/reply POST and mark_best), so a client patches its card the same
        way whatever it just did. `question` is the exact row shape the Q&A list returns,
        re-read after the write, so the thread already reflects the new answer, the
        re-sorted `answers` and any `status` change.
      required:
      - subject
      - question
      - counts
      - permissions
      properties:
        subject:
          "$ref": "#/components/schemas/TrainingSubjectBlock"
        question:
          "$ref": "#/components/schemas/TrainingQuestionRow"
        answer_id:
          type: integer
          nullable: true
          description: The answer row this call created or marked, so a client can
            highlight it without diffing. Null when a new QUESTION was asked.
          example: 23
        counts:
          type: object
          description: The owner's post-write Q&A tallies, in the same shape and the
            same keys the Q&A list returns, so a client updates its pill badges without
            re-fetching.
          properties:
            total:
              type: integer
              example: 26
            answered:
              type: integer
              example: 10
            unanswered:
              type: integer
              example: 16
        permissions:
          type: object
          description: Same block, and the same rules, as the Q&A list's `permissions`.
          properties:
            can_ask:
              type: boolean
              example: true
            can_answer:
              type: boolean
              example: true
            can_vote:
              type: boolean
              example: true
            can_mark_best:
              type: boolean
              example: false
    TrainingQaVoteState:
      type: object
      description: A question's or answer's upvote state AFTER a toggle, read back
        from the database. The toggle is NOT idempotent, so these are the only trustworthy
        values — do not predict them client-side.
      required:
      - type
      - id
      - votes_count
      - my_vote
      properties:
        type:
          type: string
          description: Which kind of row this is. SINGULAR, while the route segment
            is plural (`questions`/`answers`) — this API names a kind in a body singularly
            and in a path plurally throughout.
          enum:
          - question
          - answer
          example: answer
        id:
          type: integer
          description: The question's or answer's own id.
          example: 23
        votes_count:
          type: integer
          description: The row's upvote total after the toggle.
          example: 13
        my_vote:
          type: boolean
          description: Whether the caller now holds an upvote on it.
          example: true
        question_id:
          type: integer
          description: Present for an ANSWER only — the thread the row belongs to,
            so a client can locate the card without holding the mapping itself.
          example: 30
    TrainingVersionBrief:
      type: object
      nullable: true
      description: Factual changes since the caller's last actually completed course
        edition. Null without a prior completion. Unknown historical lineage returns
        known false. This never waives retraining.
      required:
      - known
      - changes
      properties:
        known:
          type: boolean
        previous_version:
          type: integer
        current_version:
          type: integer
        previous_version_id:
          type: integer
        current_version_id:
          type: integer
        total:
          type: integer
          description: Total changes before the 100-row response limit.
        capped:
          type: boolean
        reviewed_briefing:
          type: object
          nullable: true
          description: Human-reviewed meaning, only while both exact edition digests
            still match.
          properties:
            summary:
              type: string
            reviewed_at:
              type: string
              format: date-time
        changes:
          type: array
          maxItems: 100
          items:
            type: object
            properties:
              kind:
                type: string
                enum:
                - added
                - removed
                - changed
                - assessment_review
              title:
                type: string
              previous_lesson_id:
                type: integer
                nullable: true
              current_lesson_id:
                type: integer
                nullable: true
              media_review_needed:
                type: boolean
              before:
                type: string
                nullable: true
                description: Plain text excerpt for native text lessons only.
              after:
                type: string
                nullable: true
                description: Plain text excerpt for native text lessons only.
    TrainingDetailCta:
      type: object
      description: The screen's primary action, following the web sidebar / registration-card
        ladder. `lesson_id` is the lesson a resume/start lands on, so the client needs
        no second call to open the player — it is always the row flagged `current`
        in the returned `lessons[]`, never a lesson outside that outline.
      required:
      - action
      properties:
        action:
          type: string
          enum:
          - resume
          - start
          - review
          - open_course
          - view_certificate
          - enroll
          - buy
          - choose_session
          - view_session
          - start_path
          - continue_step
          - view_steps
          - locked
          example: resume
        course_id:
          type: integer
          description: open_course only — the selected approved language course.
        enrollment_id:
          type: integer
          description: open_course only — the actual language enrollment.
        fulfilled_at:
          type: string
          format: date-time
          nullable: true
          description: open_course only — when that course satisfied the original
            assignment.
        lesson_id:
          type: integer
          nullable: true
          description: resume/start only.
          example: 130
        quiz:
          type: boolean
          description: resume/start only — the target lesson is a quiz.
          example: false
        certificate_id:
          type: integer
          nullable: true
          description: view_certificate only.
          example: 64
        price:
          type: string
          nullable: true
          description: buy only.
          example: "$49.00"
        step_position:
          type: integer
          nullable: true
          description: continue_step only (1-based).
          example: 2
        reason:
          type: string
          nullable: true
          description: locked only. `admin_enrolls` — self-enrollment is not offered
            for this item, an administrator enrolls learners. `purchase_required`
            — a priced course on a tenant that does not sell courses natively (the
            price still rides in the payload's own `price` / `free`), so asking an
            admin is the wrong instruction. `read_only_token` — the request's API
            token lacks write:training, which the enroll / register POST behind `enroll`,
            `choose_session` or `start_path` asks; a signed-in session never gets
            it. All are values the detail serializers send; listed in full because
            a closed enum in a generated client fails the whole response on an unknown
            one.
          enum:
          - admin_enrolls
          - purchase_required
          - read_only_token
          example: admin_enrolls
    UnreadNotificationCount:
      type: integer
      nullable: true
      description: |
        **Envelope field, returned by EVERY endpoint** that renders through the
        API base controller's `render_with_piggyback` — which is effectively all
        of `/api/v1` (including every Ideas endpoint, `/apps`, `/home`, and the
        rest). It is documented once here and `$ref`-ed where relevant rather
        than repeated per endpoint.

        The value is the calling user's count of unread, active notifications in
        the current business — the number native clients paint on the app badge,
        which is why it rides along on unrelated responses instead of forcing a
        separate `/notifications/count` call.

        Gotchas:

        * Present only when the request resolved BOTH a user and a business; an
          unauthenticated/business-less response omits it entirely.
        * A count of the Inbox, so `null` for a token without
          `read:notifications` (the key stays; the native app's sign-in tokens
          carry the scope).
        * Degrades to `0` (never an error) if the count query fails.
        * It is a snapshot at response time — do not treat it as a delta, and do
          not assume it reflects any notification created by the same request.
      example: 3
    PlatformHttpMeta:
      type: object
      description: |
        The `_meta` block `render_with_piggyback` adds through
        `HttpEnhancementService`. `http.caching.etag` is the same value as the
        response's `ETag` header (a weak `W/"…"` tag fingerprinting the body
        without `_meta`); send it back as `If-None-Match` on the next GET of the
        same URL to get `304 Not Modified` while nothing changed.
      properties:
        http:
          type: object
          properties:
            caching:
              type: object
              properties:
                etag:
                  type: string
                  example: W/"3f2a9c1e0b7d4e6f8a1b2c3d4e5f6a7b"
                max_age:
                  type: integer
                private:
                  type: boolean
                  description: 'True for a signed-in caller; the response then carries
                    `Cache-Control: private, max-age=0, must-revalidate`.'
                revalidate:
                  type: boolean
                vary:
                  type: array
                  items:
                    type: string
                cacheable:
                  type: boolean
            navigation:
              type: object
            performance:
              type: object
            client_hints:
              type: object
    CollectionPaginationMeta:
      description: |
        The `meta` block of a paginated list rendered through
        `render_collection_with_piggyback`
        (`Api::V1::BaseController#build_pagination_meta`): the four
        PaginationMeta values plus the two page-direction flags. The same four
        values are also returned as the `X-Total-Count`, `X-Total-Pages`,
        `X-Current-Page` and `X-Per-Page` response headers.
      allOf:
      - "$ref": "#/components/schemas/PaginationMeta"
      - type: object
        properties:
          has_next_page:
            type: boolean
            example: true
          has_prev_page:
            type: boolean
            example: false
    CollectionEnvelope:
      type: object
      description: |
        The keys every list endpoint that renders through
        `Api::V1::BaseController#render_collection_with_piggyback` adds beside
        its own `items` (and, when paginated, `meta`). The same response
        carries an `ETag` header with
        `Cache-Control: private, max-age=0, must-revalidate`, and a GET whose
        `If-None-Match` matches that ETag is answered `304 Not Modified` with
        an empty body (see `components/responses/NotModified`).
      properties:
        total_count:
          type: integer
          description: Total rows across all pages; same value as meta.total_count
            when the list is paginated.
          example: 42
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
        _meta:
          "$ref": "#/components/schemas/PlatformHttpMeta"
    IdeaLifecycleStage:
      type: object
      description: |
        One Ideas lifecycle stage. Emitted identically wherever a stage appears —
        the ordered pipeline in `GET /ideas/config` and the stage reported by
        `PATCH /ideas/{idea_id}/stage` — because both render the same shared
        payload, so one parser handles both.
      required:
      - id
      - name
      - category
      - color
      - icon
      - position
      properties:
        id:
          type: integer
          description: Stable across a rename — safe to cache and to match against
            an idea's `stage.id`.
          example: 412
        name:
          type: string
          description: The admin-editable stage label.
          example: Reviewing
        category:
          type: string
          enum:
          - entry
          - active
          - implemented
          - declined
          description: What outcome metrics key off — never the id. Exactly one stage
            is `entry` (where new ideas land); `implemented` / `declined` are terminal
            outcomes, and moving INTO a `declined` stage records the off-ramp.
          example: active
        color:
          type: string
          description: Hex color for the stage pill.
          example: "#997404"
        icon:
          type: string
          description: |-
            Font Awesome glyph name with **no `fa-` prefix** (the same convention as `campaign.icon` and `vote_icon`), so prefix it the way you prefix those.
            DERIVED, not stored — there is no icon column and admins never pick one. The stage's LABEL wins when it is one of the two the mockup specifies (`Reviewing` → `magnifying-glass`, `Planned` → `calendar`); otherwise the CATEGORY decides: `entry` → `inbox`, `implemented` → `circle-check`, `declined` → `circle-xmark`, and `active` (or anything unrecognised) → `circle-dot`. Renaming or re-categorising a stage therefore changes its icon, and it is always the same glyph the web renders for that stage.
          example: magnifying-glass
        position:
          type: integer
          description: 0-based pipeline position.
          example: 1
    IdeasAudience:
      type: object
      description: |
        An Ideas audience setting — who a capability is open to. Used by
        `submit_audience` (who can submit ideas) and `campaign_creators` (who can
        create campaigns) in `GET /ideas/config`.

        `type: "all"` means everyone in the business. `type: "group"` means only
        members of `group`. `group` is `null` when the saved group has since been
        deleted or belongs to another business — the runtime gate DENIES in that
        case, so always drive affordances off the paired `can_*` boolean rather
        than inferring permission from `type`.
      required:
      - value
      - type
      - group
      properties:
        value:
          type: string
          description: The raw saved setting — `"all"`, or the group id as a string.
          example: all
        type:
          type: string
          enum:
          - all
          - group
          example: all
        group:
          type: object
          nullable: true
          description: 'The audience group; null for `type: "all"`, or when the saved
            group no longer resolves.'
          required:
          - id
          - name
          properties:
            id:
              type: integer
              example: 44387
            name:
              type: string
              example: Idea Submitters
    IdeasReviewPanel:
      type: object
      description: |
        An Ideas review panel — the group whose members score ideas and move them
        through the lifecycle. Used by `idea_reviewers` and `campaign_reviewers` in
        `GET /ideas/config`.

        `count` is the panel's FULL membership size. This node **names nobody** —
        there is no `members` key, and the shape is identical whatever
        `reviewer_names_visible` says. Render "Reviewed by <group.name> (<count>)"
        from it, and call a roster endpoint — both searchable, paginated, and gated on
        `reviewer_names_visible` — when you need the actual people:
        `GET /ideas/{idea_id}/reviewers` for a specific idea's panel, and
        `GET /ideas/campaigns/{id}/reviewers` for a specific campaign's.
      required:
      - group
      - count
      - source
      properties:
        group:
          type: object
          nullable: true
          description: The panel group; null only when nothing resolves (no saved
            group and no All Admins fallback), in which case `count` is 0.
          required:
          - id
          - name
          properties:
            id:
              type: integer
              example: 44390
            name:
              type: string
              example: Idea Reviewers
        count:
          type: integer
          description: The panel's full member count (not the size of `members`).
          example: 8
        source:
          type: string
          enum:
          - configured
          - fallback
          - inherited
          description: |
            Where the panel came from:
            * `configured` — an admin picked this group in Settings.
            * `fallback` — no group is saved, so ideas route to the built-in
              **All Admins** group (`idea_reviewers` only).
            * `inherited` — no campaign panel is saved, so a new campaign mirrors
              `idea_reviewers` (`campaign_reviewers` only).
          example: configured
    FormSummary:
      type: object
      description: Summary information for a form template
      properties:
        id:
          type: integer
          description: Unique form template ID
          example: 123
        name:
          type: string
          description: Form name
          example: Incident Report
        description:
          type: string
          description: Form description
          example: Report workplace incidents and safety concerns
        category:
          type: string
          description: Form category
          example: safety
        priority:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          description: Form priority level
          example: high
        status:
          type: string
          enum:
          - draft
          - published
          - archived
          description: Template status
          example: published
        field_count:
          type: integer
          description: Total number of fields in the form
          example: 8
        required_field_count:
          type: integer
          description: Number of required fields
          example: 5
        estimated_time_minutes:
          type: integer
          description: Estimated completion time in minutes
          example: 10
        has_file_uploads:
          type: boolean
          description: Whether the form supports file uploads
          example: true
        sharing_enabled:
          type: boolean
          description: Whether the form can be shared publicly
          example: false
        requires_approval:
          type: boolean
          description: Whether submissions require approval
          example: true
        created_at:
          type: string
          format: date-time
          description: Form creation timestamp
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: Last update timestamp
          example: '2024-01-15T10:00:00Z'
      required:
      - id
      - name
      - category
      - priority
      - status
      - field_count
      - estimated_time_minutes
    FormTemplate:
      type: object
      description: Form data optimized for rendering
      properties:
        template:
          "$ref": "#/components/schemas/FormTemplate"
        draft_submission:
          "$ref": "#/components/schemas/FormSubmission"
          nullable: true
          description: 'Existing draft submission for progress restoration. NOTE:
            the `submission_data` property is intentionally omitted here — each field
            in `fields[]` now carries its own `submission_data` (sourced from this
            draft or the requested submission), so the map is not repeated on this
            object.'
        mobile_config:
          type: object
          description: Mobile-specific rendering configuration
          properties:
            offline_capable:
              type: boolean
              description: Whether the form supports offline mode
              example: true
            voice_input_enabled:
              type: boolean
              description: Whether voice input is available
              example: true
            photo_capture_enabled:
              type: boolean
              description: Whether photo capture is available
              example: true
            gps_enabled:
              type: boolean
              description: Whether GPS location is required
              example: false
            estimated_time_minutes:
              type: integer
              description: Estimated completion time
              example: 10
        theme:
          type: object
          description: The colour picked in the form's Design modal (a custom palette's
            Brand primary, or the chosen preset's). Both values are `null` when the
            form follows the business theme — keep the business branding.
          properties:
            brand_primary:
              type: string
              nullable: true
              description: The picked brand colour as a
              example: "#f5c518"
            brand_primary_fill:
              type: string
              nullable: true
              description: The same colour moved until white button text clears it
                (WCAG AA, 4.5:1) — the fill the phone web fill page paints behind
                its buttons, links and checked controls. `null` when the form follows
                the business theme.
              example: "#7d6302"
        prefill_data:
          type: object
          description: Pre-filled field values
          nullable: true
          additionalProperties: true
      required:
      - template
      - mobile_config
    FormField:
      type: object
      description: Individual form field definition
      properties:
        id:
          type: integer
          description: Unique field ID
          example: 456
        field_name:
          type: string
          description: Internal field name (used for data storage)
          example: incident_type
        field_type:
          type: string
          enum:
          - text
          - textarea
          - number
          - email
          - phone
          - url
          - select
          - multiselect
          - checkbox
          - radio
          - date
          - datetime
          - time
          - file
          - image
          - video
          - audio
          - signature
          - location
          - gps
          - barcode
          - qr_code
          description: Field input type
          example: select
        label:
          type: string
          description: Field label for display
          example: Incident Type
        is_editable:
          type: boolean
          description: Whether the client should render this field as user-editable.
            False for externally-sourced/computed inputs (lookup, rest_api), the table
            layout block, and navigation-control fields (conditional_navigator, flow_controller,
            smart_button, progress_gate, page_jump, section_toggle); true for all
            other field types.
          example: true
        description:
          type: string
          description: Help text for the field
          nullable: true
          example: Select the type of incident that occurred
        placeholder:
          type: string
          description: Placeholder text
          nullable: true
          example: Choose incident type...
        required:
          type: boolean
          description: Whether the field is required
          example: true
        position:
          type: integer
          description: Field order position
          example: 1
        section:
          type: string
          description: Form section grouping
          nullable: true
          example: incident_details
        options:
          type: array
          description: Options for select/radio/checkbox fields using rich object
            format
          nullable: true
          items:
            type: object
            properties:
              value:
                type: string
                description: Option value stored in database
                example: injury
              label:
                type: string
                description: Option display label shown to users
                example: Injury
              description:
                type: string
                description: Optional description for the option
                nullable: true
            required:
            - value
            - label
        validation_rules:
          type: object
          description: Field validation rules
          nullable: true
          properties:
            required:
              type: boolean
              example: true
            min_length:
              type: integer
              example: 10
            max_length:
              type: integer
              example: 500
            min_value:
              type: number
              example: 0
            max_value:
              type: number
              example: 100
            pattern:
              type: string
              example: "^[A-Za-z0-9]+$"
            unique:
              type: boolean
              description: '"Unique values only" — when true, the same value may not
                be submitted twice for this field. Present only when enabled.'
              example: true
            custom_message:
              type: string
              description: Custom validation message to show instead of the generic
                one when this field fails validation. Present only when configured.
              example: Please enter a valid employee ID.
        conditional_logic:
          type: object
          description: Field conditional display logic
          nullable: true
          properties:
            show_if:
              type: object
              properties:
                field:
                  type: string
                  description: Field name to check
                operator:
                  type: string
                  enum:
                  - equals
                  - not_equals
                  - contains
                  - greater_than
                  - less_than
                value:
                  type: string
                  description: Value to compare against
        configuration:
          type: object
          description: Field-specific configuration settings
          nullable: true
          additionalProperties: true
        submission_data:
          nullable: true
          description: 'This field''s saved value, ALWAYS present on GET /api/v1/forms/:id.
            Source: the submission named by `submission_id` (prefill for resume/edit)
            when supplied, otherwise the caller''s DRAFT submission for this form.
            `null` when there is no such submission/draft data, or when the referenced
            submission/draft never answered this field. Type matches the field (string,
            number, array for galleries, object for file references). For media fields
            (file/image/video/audio/gallery/signature) each file reference is enriched
            with a resolved absolute `url` (alongside its `file_id`) so the client
            can render/download the previously uploaded file — a single object for
            file/image/video/audio/signature, an array of such objects for a gallery.
            An identity or account number held sealed (an SSN, tax id, passport, bank
            routing / account or card number) is never sent back: its value is ""
            and `on_file` carries the mask.'
        on_file:
          type: string
          description: 'Present only when this field''s saved value is an identity
            or account number held encrypted on the submission: its mask, e.g. "****6789".
            The input starts empty and is not required (`required` is false); an empty
            answer keeps the number on file, a new one replaces it.'
          example: "****6789"
      required:
      - id
      - field_name
      - field_type
      - label
      - required
      - position
      - submission_data
    CompensationProfile:
      type: object
      description: Employee compensation profile with current details and policies
      properties:
        id:
          type: integer
          description: User business ID
          example: 123
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            title:
              type: string
              nullable: true
              example: Software Engineer
            hire_date:
              type: string
              format: date
              nullable: true
              example: '2023-01-15'
        current_compensation:
          type: object
          properties:
            type:
              type: string
              enum:
              - salary
              - hourly
              - commission
              - contract
              example: salary
            annual_salary:
              type: number
              nullable: true
              example: 75000.0
            hourly_rate:
              type: number
              nullable: true
              example: 36.06
            currency:
              type: string
              example: USD
            pay_frequency:
              type: string
              enum:
              - weekly
              - biweekly
              - monthly
              - quarterly
              - annually
              example: monthly
            effective_date:
              type: string
              format: date
              nullable: true
              example: '2024-01-01'
        policies:
          type: object
          properties:
            can_view_details:
              type: boolean
              example: true
            can_request_changes:
              type: boolean
              example: true
            request_frequency:
              type: string
              enum:
              - annual
              - semi_annual
              - unlimited
              example: annual
            mobile_access_enabled:
              type: boolean
              example: true
        next_review_eligible:
          type: string
          format: date
          nullable: true
          description: The next compensation review date your organization has set;
            null when none is set.
          example: '2024-12-01'
        last_updated:
          type: object
          description: The most recent compensation change on record; both fields
            are null when there is none.
          properties:
            date:
              type: string
              format: date-time
              nullable: true
              description: When the change was recorded.
              example: '2024-01-15T10:30:00Z'
            by:
              type: string
              nullable: true
              description: Who made the change, or "System" for a change recorded
                with no author (an automated step increase or import).
              example: Jane Smith
    CompensationHistoryRecord:
      type: object
      description: Individual compensation change record
      properties:
        id:
          type: integer
          example: 789
        change_date:
          type: string
          format: date-time
          example: '2024-01-15T10:30:00Z'
        effective_date:
          type: string
          format: date
          example: '2024-02-01'
        change_reason:
          type: string
          example: Annual merit increase
        changed_by:
          type: object
          properties:
            id:
              type: integer
              example: 101
            name:
              type: string
              example: Jane Manager
        changes:
          type: object
          properties:
            compensation_type:
              type: object
              properties:
                from:
                  type: string
                  nullable: true
                  example: salary
                to:
                  type: string
                  nullable: true
                  example: salary
            annual_salary:
              type: object
              properties:
                from:
                  type: number
                  nullable: true
                  example: 70000.0
                to:
                  type: number
                  nullable: true
                  example: 75000.0
            hourly_rate:
              type: object
              properties:
                from:
                  type: number
                  nullable: true
                  example:
                to:
                  type: number
                  nullable: true
                  example:
            pay_frequency:
              type: object
              properties:
                from:
                  type: string
                  nullable: true
                  example: monthly
                to:
                  type: string
                  nullable: true
                  example: monthly
            currency:
              type: object
              properties:
                from:
                  type: string
                  nullable: true
                  example: USD
                to:
                  type: string
                  nullable: true
                  example: USD
        change_summary:
          type: string
          example: 'Salary: $70,000 → $75,000'
        change_direction:
          type: string
          enum:
          - increase
          - decrease
          - neutral
          example: increase
        is_retroactive:
          type: boolean
          example: false
        effective_soon:
          type: boolean
          example: true
    CompensationInsights:
      type: object
      description: Personalized compensation insights and analytics
      properties:
        summary:
          type: object
          properties:
            current_annualized_value:
              type: number
              nullable: true
              example: 75000.0
            compensation_type:
              type: string
              example: salary
            currency:
              type: string
              example: USD
            tenure_months:
              type: integer
              example: 18
        recent_activity:
          type: object
          properties:
            changes_last_12_months:
              type: integer
              example: 1
            last_change_date:
              type: string
              format: date
              nullable: true
              example: '2024-01-15'
        request_eligibility:
          type: object
          properties:
            can_request_now:
              type: boolean
              example: true
            next_eligible_date:
              type: string
              format: date
              nullable: true
              example: '2025-01-15'
        performance_link:
          type: object
          description: Not tracked yet — both fields are always null.
          properties:
            has_recent_review:
              type: boolean
              nullable: true
              description: Not tracked yet; always null.
              example:
            merit_increase_eligible:
              type: boolean
              nullable: true
              description: Not tracked yet; always null.
              example:
    CompensationCalculation:
      type: object
      description: Compensation calculation result
      properties:
        compensation_type:
          type: string
          enum:
          - salary
          - hourly
          example: salary
        annual_salary:
          type: number
          nullable: true
          example: 75000.0
        calculated_hourly_rate:
          type: number
          nullable: true
          example: 36.06
        hourly_rate:
          type: number
          nullable: true
          example:
        calculated_annual_salary:
          type: number
          nullable: true
          example:
        annual_hours_basis:
          type: number
          example: 2080.0
        currency:
          type: string
          example: USD
    CompensationRequestSummary:
      type: object
      description: Summary view of compensation request
      properties:
        id:
          type: integer
          example: 456
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - cancelled
          example: pending
        created_at:
          type: string
          format: date-time
          example: '2024-01-20T14:30:00Z'
        effective_date:
          type: string
          format: date
          example: '2024-03-01'
        change_summary:
          type: string
          example: 'Salary: $75,000 → $80,000'
        change_impact:
          type: object
          properties:
            type:
              type: string
              enum:
              - increase
              - decrease
              - neutral
              example: increase
            amount:
              type: number
              example: 5000.0
            percentage:
              type: number
              nullable: true
              example: 6.7
            description:
              type: string
              example: Increase of $5,000 (6.7%)
        approver:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        days_pending:
          type: integer
          example: 5
        is_urgent:
          type: boolean
          example: false
        effective_soon:
          type: boolean
          example: true
    CompensationRequestDetail:
      type: object
      description: Detailed view of compensation request
      properties:
        id:
          type: integer
          example: 456
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - cancelled
          example: pending
        created_at:
          type: string
          format: date-time
          example: '2024-01-20T14:30:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-20T14:30:00Z'
        effective_date:
          type: string
          format: date
          example: '2024-03-01'
        justification:
          type: string
          example: Requesting salary increase based on performance review and market
            analysis
        current_compensation:
          type: object
          properties:
            type:
              type: string
              example: salary
            annual_salary:
              type: number
              nullable: true
              example: 75000.0
            hourly_rate:
              type: number
              nullable: true
              example:
            pay_frequency:
              type: string
              example: monthly
            currency:
              type: string
              example: USD
        requested_compensation:
          type: object
          properties:
            type:
              type: string
              example: salary
            annual_salary:
              type: number
              nullable: true
              example: 80000.0
            hourly_rate:
              type: number
              nullable: true
              example:
            pay_frequency:
              type: string
              example: monthly
            currency:
              type: string
              example: USD
        changes:
          type: object
          properties:
            has_salary_change:
              type: boolean
              example: true
            has_hourly_change:
              type: boolean
              example: false
            has_type_change:
              type: boolean
              example: false
            has_frequency_change:
              type: boolean
              example: false
            has_currency_change:
              type: boolean
              example: false
            summary:
              type: string
              example: 'Salary: $75,000 → $80,000'
            impact:
              type: object
              properties:
                type:
                  type: string
                  example: increase
                amount:
                  type: number
                  example: 5000.0
                percentage:
                  type: number
                  example: 6.7
                description:
                  type: string
                  example: Increase of $5,000 (6.7%)
        approval:
          type: object
          properties:
            approver:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                  example: 789
                name:
                  type: string
                  example: Jane Manager
            approved_at:
              type: string
              format: date-time
              nullable: true
              example:
            rejected_at:
              type: string
              format: date-time
              nullable: true
              example:
            manager_notes:
              type: string
              nullable: true
              example:
        status_info:
          type: object
          properties:
            days_pending:
              type: integer
              example: 5
            is_urgent:
              type: boolean
              example: false
            effective_soon:
              type: boolean
              example: true
            can_edit:
              type: boolean
              example: true
            can_cancel:
              type: boolean
              example: true
    CompensationRequestInput:
      type: object
      description: Input schema for creating/updating compensation requests
      required:
      - justification
      - requested_compensation_type
      properties:
        justification:
          type: string
          minLength: 10
          maxLength: 1000
          example: Requesting salary increase based on performance review and market
            analysis
        effective_date:
          type: string
          format: date
          example: '2024-03-01'
        requested_compensation_type:
          type: string
          enum:
          - salary
          - hourly
          - commission
          - contract
          example: salary
        requested_annual_salary:
          type: number
          minimum: 0
          nullable: true
          example: 80000.0
        requested_hourly_rate:
          type: number
          minimum: 0
          nullable: true
          example:
        requested_pay_frequency:
          type: string
          enum:
          - weekly
          - biweekly
          - monthly
          - quarterly
          - annually
          example: monthly
        requested_currency:
          type: string
          example: USD
    CompensationRequestStatistics:
      type: object
      description: Statistics about employee's compensation requests
      properties:
        total:
          type: integer
          example: 3
        pending:
          type: integer
          example: 1
        approved:
          type: integer
          description: Approved and applied to pay.
          example: 1
        not_applied:
          type: integer
          description: Approved, but the pay change could not be applied (see each
            request's not-applied reason).
          example: 0
        rejected:
          type: integer
          example: 0
        cancelled:
          type: integer
          example: 1
    EPMSDashboard:
      type: object
      description: EPMS dashboard data with aggregated goals, reviews, feedback, and
        meetings
      properties:
        goals:
          type: object
          properties:
            active_count:
              type: integer
              example: 5
            completed_count:
              type: integer
              example: 12
            overdue_count:
              type: integer
              example: 2
            recent_updates:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSGoal"
        reviews:
          type: object
          properties:
            pending_count:
              type: integer
              example: 1
            in_progress_count:
              type: integer
              example: 2
            completed_count:
              type: integer
              example: 8
            recent_reviews:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSPerformanceReview"
        feedback:
          type: object
          properties:
            received_count:
              type: integer
              example: 15
            pending_acknowledgment:
              type: integer
              example: 3
            recent_feedback:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSContinuousFeedback"
        meetings:
          type: object
          properties:
            upcoming_count:
              type: integer
              example: 4
            recent_count:
              type: integer
              example: 6
            recent_meetings:
              type: array
              items:
                "$ref": "#/components/schemas/EPMSMeeting"
        action_items:
          type: array
          items:
            type: object
            properties:
              type:
                type: string
                enum:
                - goal_update
                - review_submit
                - feedback_acknowledge
                - meeting_schedule
              title:
                type: string
              due_date:
                type: string
                format: date
                nullable: true
              priority:
                type: string
                enum:
                - low
                - medium
                - high
                - critical
    EPMSTeamDashboard:
      type: object
      description: Team dashboard data for managers
      properties:
        team_stats:
          type: object
          properties:
            team_size:
              type: integer
              example: 12
            active_goals:
              type: integer
              example: 45
            pending_reviews:
              type: integer
              example: 8
            pending_approvals:
              type: integer
              example: 5
        team_goals:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoal"
        pending_reviews:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSPerformanceReview"
        team_feedback:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSContinuousFeedback"
        upcoming_meetings:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSMeeting"
    EPMSGoal:
      type: object
      description: Employee goal with progress tracking. Progress updates are returned
        as a sibling array in GET /goals/{id}, not nested in the goal object.
      properties:
        id:
          type: integer
          example: 123
        title:
          type: string
          example: Increase sales by 20%
        description:
          type: string
          nullable: true
          example: Achieve 20% growth in Q1 sales
        goal_type:
          type: string
          enum:
          - performance
          - development
          - behavior
          - project
          - skill
          example: performance
        goal_type_label:
          type: string
          nullable: true
          description: Human-readable label for goal type
        goal_category:
          type: string
          nullable: true
          description: The focus area category key for this goal
          example: professional_development
        goal_category_label:
          type: string
          nullable: true
          description: Human-readable label for the goal category
          example: Professional Development
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          example: high
        priority_label:
          type: string
          nullable: true
          description: Human-readable label for priority
        status:
          type: string
          enum:
          - draft
          - in_review
          - active
          - on_hold
          - completed
          - cancelled
          - overdue
          example: active
        display_status:
          type: string
          nullable: true
          description: Human-readable display status
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_date:
          type: string
          format: date
          example: '2026-03-31'
        completed_date:
          type: string
          format: date
          nullable: true
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 45.5
        weight_percentage:
          type: number
          nullable: true
          example: 30.0
        workflow_stage:
          type: string
          enum:
          - draft
          - employee_review
          - manager_finalized
          - leadership_approved
          description: Current workflow stage
        workflow_stage_label:
          type: string
          nullable: true
        is_smart_goal:
          type: boolean
          example: true
        smart_score:
          type: number
          nullable: true
        on_track:
          type: boolean
          nullable: true
        days_until_due:
          type: integer
          nullable: true
        is_overdue:
          type: boolean
          example: false
        progress_update_allowed:
          type: boolean
          description: Whether progress updates can be submitted for this goal
        success_criteria:
          type: string
          nullable: true
          description: Present when include_details is true (e.g. show endpoint)
          example: Reach $500K in sales
        smart_criteria:
          type: object
          nullable: true
          description: Present when include_details is true
          properties:
            is_specific:
              type: boolean
            is_measurable:
              type: boolean
            is_achievable:
              type: boolean
            is_relevant:
              type: boolean
            is_time_bound:
              type: boolean
        progress_updates_count:
          type: integer
          description: Present when include_details is true
        latest_progress_update:
          "$ref": "#/components/schemas/EPMSProgressUpdate"
          nullable: true
          description: Present when include_details is true
        can_edit:
          type: boolean
          description: Present when include_details is true
        can_complete:
          type: boolean
          description: Present when include_details is true
        can_delete:
          type: boolean
          description: Present when include_details is true
        requires_manager_approval:
          type: boolean
          description: Present when include_details is true
        is_fully_approved:
          type: boolean
          description: Present when include_details is true
        in_review_stage:
          type: boolean
          description: Present when include_details is true
        can_cancel:
          type: boolean
          description: Present when include_details is true
        can_put_on_hold:
          type: boolean
          description: Present when include_details is true
        can_reactivate:
          type: boolean
          description: Present when include_details is true
        can_send_to_employee:
          type: boolean
          description: Present when include_details is true
        can_employee_review:
          type: boolean
          description: Present when include_details is true
        can_manager_finalize:
          type: boolean
          description: Present when include_details is true
        can_leadership_approve:
          type: boolean
          description: Present when include_details is true
        manager_approved_at:
          type: string
          format: date-time
          nullable: true
          description: Present when include_details is true
        leadership_approved_at:
          type: string
          format: date-time
          nullable: true
          description: Present when include_details is true
        employee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            email:
              type: string
              nullable: true
              example: john@example.com
            job_title:
              type: string
              nullable: true
              description: 'The job title this business shows for the person: their
                membership''s title (set by the profile''s Job Title select), else
                the profile''s own title.'
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
    EPMSGoalInput:
      type: object
      description: Input schema for creating/updating goals
      required:
      - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Increase sales by 20%
        description:
          type: string
          maxLength: 2000
          example: Achieve 20% growth in Q1 sales
        goal_type:
          type: string
          enum:
          - performance
          - development
          - behavior
          - project
          - skill
          example: performance
        goal_category:
          type: string
          maxLength: 50
          description: 'Focus area category key. Required on create for non-department
            goals. Must be a valid category configured for the business. Use `GET
            /epms/goals/categories` to retrieve valid values. Default categories for
            IC: professional_development, lead_self, work_with_others, contribute_to_business.
            Default categories for leaders: professional_development, lead_self, lead_others,
            lead_the_business. Business configuration may differ.

            '
          example: professional_development
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          example: high
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_date:
          type: string
          format: date
          example: '2026-03-31'
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 0
        weight_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 30.0
        success_criteria:
          type: string
          maxLength: 500
          example: Reach $500K in sales
        employee_id:
          type: integer
          example: 456
        is_specific:
          type: boolean
          example: true
        is_measurable:
          type: boolean
          example: true
        is_achievable:
          type: boolean
          example: true
        is_relevant:
          type: boolean
          example: true
        is_time_bound:
          type: boolean
          example: true
    EPMSGoalCategoryOption:
      type: object
      description: A goal category option with value and human-readable label
      properties:
        value:
          type: string
          description: Category key to send on create/update
          example: professional_development
        label:
          type: string
          description: Human-readable label for display
          example: Professional Development
    EPMSGoalCategoriesResponse:
      type: object
      description: Response containing valid goal categories for a user's role
      properties:
        categories:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
    EPMSGoalTemplateOption:
      type: object
      description: A goal template available for prefilling the create form
      properties:
        id:
          type: integer
          example: 1
        title:
          type: string
          example: Improve Task Completion
        description:
          type: string
          nullable: true
          example: Focus on completing tasks on time and improving quality.
        goal_type:
          type: string
          example: performance
        priority:
          type: string
          example: medium
        success_criteria:
          type: string
          nullable: true
          example: Task completion rate above 90%
        resources_needed:
          type: string
          nullable: true
          example: Project management tool access
        measurement_method:
          type: string
          nullable: true
          example: Weekly task completion reports
        is_default:
          type: boolean
          example: true
        smart_criteria:
          type: object
          description: Which SMART criteria the template meets (GoalTemplate#smart_criteria_hash)
          properties:
            is_specific:
              type: boolean
            is_measurable:
              type: boolean
            is_achievable:
              type: boolean
            is_relevant:
              type: boolean
            is_time_bound:
              type: boolean
    EPMSGoalChecklistItem:
      type: object
      description: A SMART checklist item
      properties:
        key:
          type: string
          example: is_specific
        label:
          type: string
          example: Specific
    EPMSGoalChecklist:
      type: object
      description: SMART goal checklist configuration
      properties:
        enforced:
          type: boolean
          description: Whether the SMART checklist is enforced for goal creation
          example: false
        items:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalChecklistItem"
    EPMSGoalCreationConfigResponse:
      type: object
      description: All dynamic properties needed to render the goal create form
      properties:
        goal_templates:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalTemplateOption"
        goal_types:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        priorities:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        goal_categories:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalCategoryOption"
        goal_checklist:
          "$ref": "#/components/schemas/EPMSGoalChecklist"
        requires_approval:
          type: boolean
          example: true
    EPMSSmartCriterionResult:
      type: object
      properties:
        score:
          type: integer
          minimum: 1
          maximum: 10
          example: 8
        feedback:
          type: string
          example: The goal is specific and clear
        suggestion:
          type: string
          example: Include specific teams responsible
    EPMSSmartAnalysisResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        overall_score:
          type: integer
          minimum: 0
          maximum: 100
          example: 75
        criteria:
          type: object
          properties:
            specific:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            measurable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            achievable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            relevant:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            time_bound:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
        improved_title:
          type: string
          nullable: true
        improved_description:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
        goal:
          "$ref": "#/components/schemas/EPMSGoal"
    EPMSSmartTextAnalysisResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        overall_score:
          type: integer
          minimum: 0
          maximum: 100
        criteria:
          type: object
          properties:
            specific:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            measurable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            achievable:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            relevant:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
            time_bound:
              "$ref": "#/components/schemas/EPMSSmartCriterionResult"
        improved_title:
          type: string
          nullable: true
        improved_description:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
    EPMSGoalTemplate:
      type: object
      description: Goal template summary
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
        goal_type:
          type: string
        goal_type_label:
          type: string
        priority:
          type: string
        priority_label:
          type: string
        is_default:
          type: boolean
        template_type:
          type: string
          enum:
          - system
          - custom
        smart_score:
          type: integer
        is_smart_template:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    EPMSGoalTemplateDetail:
      allOf:
      - "$ref": "#/components/schemas/EPMSGoalTemplate"
      - type: object
        properties:
          success_criteria:
            type: string
            nullable: true
          resources_needed:
            type: string
            nullable: true
          measurement_method:
            type: string
            nullable: true
          smart_criteria:
            type: object
            properties:
              is_specific:
                type: boolean
              is_measurable:
                type: boolean
              is_achievable:
                type: boolean
              is_relevant:
                type: boolean
              is_time_bound:
                type: boolean
          missing_smart_criteria:
            type: array
            items:
              type: string
          usage_statistics:
            type: object
            properties:
              total_goals:
                type: integer
              active_goals:
                type: integer
              completed_goals:
                type: integer
              completion_rate:
                type: number
              last_used:
                type: string
                format: date-time
                nullable: true
    EPMSGoalTemplateListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoalTemplate"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
    EPMSProgressUpdate:
      type: object
      description: Goal progress update record
      properties:
        id:
          type: integer
          example: 789
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          example: 50.0
        update_notes:
          type: string
          nullable: true
          example: Halfway through the quarter, on track
        update_date:
          type: string
          format: date
          example: '2026-01-15'
        update_type:
          type: string
          enum:
          - regular
          - milestone
          - completion
          - revision
          - comment
          example: regular
        challenges_faced:
          type: string
          nullable: true
        support_needed:
          type: string
          nullable: true
        attachments:
          type: array
          items:
            type: string
          nullable: true
        updated_by:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T10:30:00Z'
    EPMSProgressUpdateInput:
      type: object
      description: Input schema for progress updates
      properties:
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          description: Required for all types except 'comment'
          example: 50.0
        update_notes:
          type: string
          maxLength: 1000
          example: Halfway through the quarter, on track
        update_type:
          type: string
          enum:
          - regular
          - milestone
          - completion
          - revision
          - comment
          default: regular
          example: regular
        challenges_faced:
          type: string
          nullable: true
          description: Obstacles or challenges encountered
          example: Dependency on external vendor delayed delivery
        support_needed:
          type: string
          nullable: true
          description: Support or resources needed
          example: Need additional budget approval for tooling
        attachments:
          type: array
          items:
            type: string
          nullable: true
          description: File references/attachment identifiers
    EPMSGoalStats:
      type: object
      description: Goal statistics for dashboard
      properties:
        total_goals:
          type: integer
          example: 20
        active_goals:
          type: integer
          example: 12
        completed_goals:
          type: integer
          example: 6
        overdue_goals:
          type: integer
          example: 2
        on_hold_goals:
          type: integer
          example: 0
        cancelled_goals:
          type: integer
          example: 0
        due_soon_count:
          type: integer
          description: Goals due within the next 7 days
          example: 3
        average_progress:
          type: number
          example: 45.5
        completion_rate:
          type: number
          description: Percentage of completed goals (0-100)
          example: 30.0
        by_status:
          type: object
          additionalProperties:
            type: integer
          description: Counts per status (draft, active, on_hold, completed, cancelled,
            in_review, overdue)
        by_priority:
          type: object
          additionalProperties:
            type: integer
          description: Counts per priority (low, medium, high, critical)
        by_type:
          type: object
          additionalProperties:
            type: integer
          description: Counts per goal type (performance, development, behavior, project,
            skill)
    EPMSPerformanceReview:
      type: object
      description: 'Performance review with assessment details, as Api::V1::Epms::BaseController
        #format_performance_review_response sends it. The fields under "detail only"
        are present on the single-review responses (show, create, update, submit,
        approve), not on the list.'
      properties:
        id:
          type: integer
          example: 234
        title:
          type: string
          example: Q1 2026 Performance Review
        status:
          type: string
          enum:
          - created
          - in_progress
          - completed
          - approved
          - archived
          example: in_progress
        display_status:
          type: string
          description: The status as shown — `overdue` for a pending review past its
            due date, else `status`.
          enum:
          - created
          - in_progress
          - completed
          - approved
          - archived
          - overdue
          example: in_progress
        status_color:
          type: string
          description: Bootstrap badge color variant for the display status
          enum:
          - info
          - primary
          - warning
          - success
          - danger
          - secondary
          example: primary
        display_status_label:
          type: string
          description: The display status in words, as the web list labels it.
          example: In Progress
        review_type:
          type: string
          enum:
          - annual
          - biannual
          - quarterly
          - probationary
          - ad_hoc
          example: quarterly
        review_period:
          type: string
          description: The review period in words.
          example: Jan 2026 - Mar 2026
        review_period_start:
          type: string
          format: date
          example: '2026-01-01'
        review_period_end:
          type: string
          format: date
          example: '2026-03-31'
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-04-15'
        overall_rating:
          type: number
          nullable: true
          description: The numeric rating — null unless the caller may see the manager's
            work and the business shows numeric scores.
          example: 4.5
        overall_rating_display:
          type: string
          nullable: true
          description: The rating as the business displays it (a number, a label,
            or hidden); null when the caller may not see the manager's work.
          example: 4.5 / 5
        rating_scale:
          type: integer
          example: 5
        completion_percentage:
          type: number
          example: 60
        is_overdue:
          type: boolean
          example: false
        days_until_due:
          type: integer
          nullable: true
          example: 12
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            email:
              type: string
              nullable: true
              example: john.doe@example.com
            job_title:
              type: string
              nullable: true
              description: 'The job title this business shows for the person: their
                membership''s title (set by the profile''s Job Title select), else
                the profile''s own title.'
              example: Software Engineer
        reviewer:
          type: object
          description: The reviewer; absent when the review has none.
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
            email:
              type: string
              nullable: true
              example: jane.manager@example.com
        manager_comments:
          type: string
          nullable: true
          description: Detail only. Null when the caller may not see the manager's
            work.
          example: Great progress this quarter
        employee_self_assessment:
          type: string
          nullable: true
          description: Detail only.
        goals_count:
          type: integer
          description: Detail only.
        completed_goals_count:
          type: integer
          description: Detail only.
        goals_completion_percentage:
          type: number
          description: Detail only.
        can_edit:
          type: boolean
          description: Detail only.
        can_submit:
          type: boolean
          description: Detail only.
        can_approve:
          type: boolean
          description: Detail only.
        self_assessment_required:
          type: boolean
          description: Detail only.
        employee_submitted_self_assessment:
          type: boolean
          description: Detail only.
        meeting_completed:
          type: boolean
          description: Detail only.
    EPMSPerformanceReviewInput:
      type: object
      description: Input schema for creating/updating performance reviews
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Q1 2026 Performance Review
        employee_id:
          type: integer
          example: 456
        review_type:
          type: string
          enum:
          - annual
          - quarterly
          - probation
          - project
          - performance_improvement
          example: quarterly
        review_period_start:
          type: string
          format: date
          example: '2026-01-01'
        review_period_end:
          type: string
          format: date
          example: '2026-03-31'
        due_date:
          type: string
          format: date
          example: '2026-04-15'
        performance_review_template_id:
          type: integer
          nullable: true
        manager_comments:
          type: string
          maxLength: 5000
          example: Great progress this quarter
        overall_rating:
          type: number
          minimum: 0
          maximum: 5
          example: 4.5
    EPMSSelfAssessmentInput:
      type: object
      description: Input schema for self-assessment submission
      properties:
        overall_summary:
          type: string
          maxLength: 2000
          example: I achieved all my goals this quarter
        strengths:
          type: string
          maxLength: 1000
          example: Strong communication and leadership
        areas_for_improvement:
          type: string
          maxLength: 1000
          example: Time management
        accomplishments:
          type: string
          maxLength: 2000
          example: Completed 3 major projects
        goals_reflection:
          type: string
          maxLength: 1000
          example: Met 90% of goals
        training_needs:
          type: string
          maxLength: 500
          example: Advanced project management
        career_aspirations:
          type: string
          maxLength: 1000
          example: Lead a larger team
        self_rating:
          type: number
          minimum: 0
          maximum: 5
          example: 4.0
        ratings:
          type: array
          items:
            type: object
            properties:
              category:
                type: string
                example: Communication
              rating:
                type: number
                minimum: 0
                maximum: 5
                example: 4.5
              comments:
                type: string
                example: Excellent
        goals:
          type: array
          items:
            type: object
            properties:
              goal_id:
                type: integer
                example: 123
              achievement_notes:
                type: string
                example: Exceeded expectations
              self_rating:
                type: number
                minimum: 0
                maximum: 5
                example: 5.0
    EPMSPerformanceReviewStats:
      type: object
      description: Performance review statistics
      properties:
        total_reviews:
          type: integer
          example: 15
        pending_reviews:
          type: integer
          example: 5
        completed_reviews:
          type: integer
          example: 8
        overdue_reviews:
          type: integer
          example: 2
        due_soon_count:
          type: integer
          example: 3
        completion_rate:
          type: number
          example: 53.3
        by_status:
          type: object
          description: Count of reviews by status key
          additionalProperties:
            type: integer
          example:
            created: 2
            in_progress: 5
            completed: 3
            approved: 2
            archived: 1
        by_type:
          type: object
          description: Count of reviews by review_type key
          additionalProperties:
            type: integer
          example:
            annual: 8
            quarterly: 5
            ad_hoc: 2
        average_rating:
          type: number
          nullable: true
          example: 4.2
    EPMSMyReviewSummary:
      type: object
      description: Personal review summary for the current user (matches "My Review
        Status" widget)
      properties:
        total_reviews:
          type: integer
          description: Total number of reviews where the user is the employee
          example: 5
        completed_reviews:
          type: integer
          description: Reviews with status completed or approved
          example: 3
        pending_reviews:
          type: integer
          description: Reviews with status created or in_progress
          example: 1
        average_rating:
          type: number
          description: Average overall_rating across completed/approved reviews (rounded
            to 1 decimal, defaults to 0)
          example: 4.2
        has_active_review:
          type: boolean
          description: Whether the user has an active (created or in_progress) review
          example: true
        current_review:
          type: object
          nullable: true
          description: The most recent active review summary
          properties:
            id:
              type: integer
            title:
              type: string
            status:
              type: string
            display_status:
              type: string
            status_color:
              type: string
              enum:
              - info
              - primary
              - success
              - danger
              - secondary
            start_date:
              type: string
              format: date
              nullable: true
            due_date:
              type: string
              format: date
              nullable: true
            days_until_due:
              type: integer
              nullable: true
            completion_percentage:
              type: integer
            requires_self_assessment:
              type: boolean
            self_assessment_completed:
              type: boolean
        pending_actions:
          type: array
          description: Status-based action items for the user
          items:
            type: object
            properties:
              type:
                type: string
                enum:
                - begin_review
                - complete_self_assessment
                - awaiting_approval
              text:
                type: string
              priority:
                type: string
                enum:
                - high
                - medium
                - low
    EPMSContinuousFeedback:
      type: object
      description: Continuous feedback record
      properties:
        id:
          type: integer
          example: 345
        subject:
          type: string
          example: Great work on the project
        content:
          type: string
          example: Your presentation was excellent and well-received by the team.
        feedback_type:
          type: string
          enum:
          - praise
          - constructive
          - goal_progress
          - general
          example: praise
        status:
          type: string
          enum:
          - draft
          - sent
          - acknowledged
          - archived
          example: sent
        visibility:
          type: string
          enum:
          - private
          - public
          - manager_only
          example: private
        is_anonymous:
          type: boolean
          example: false
        giver:
          type: object
          nullable: true
          description: 'Feedback giver info. Returns only `{ name: "Anonymous" }`
            when feedback is anonymous.'
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
            email:
              type: string
              format: email
              example: jane.manager@company.com
            job_title:
              type: string
              nullable: true
              description: 'The job title this business shows for the person: their
                membership''s title (set by the profile''s Job Title select), else
                the profile''s own title.'
              example: Engineering Manager
            profile_photo_url:
              type: string
              format: uri
              nullable: true
              description: Full-size profile photo URL (200x200)
              example: https://example.com/photos/jane-200x200.jpg
            profile_photo_thumbnail_url:
              type: string
              format: uri
              nullable: true
              description: Thumbnail profile photo URL (40x40)
              example: https://example.com/photos/jane-40x40.jpg
        receiver:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
            email:
              type: string
              format: email
              example: john.doe@company.com
            job_title:
              type: string
              nullable: true
              description: 'The job title this business shows for the person: their
                membership''s title (set by the profile''s Job Title select), else
                the profile''s own title.'
              example: Senior Product Manager
            profile_photo_url:
              type: string
              format: uri
              nullable: true
              description: Full-size profile photo URL (200x200)
              example: https://example.com/photos/john-200x200.jpg
            profile_photo_thumbnail_url:
              type: string
              format: uri
              nullable: true
              description: Thumbnail profile photo URL (40x40)
              example: https://example.com/photos/john-40x40.jpg
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-01-20T10:00:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
        sent_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-01-15T15:00:00Z'
    EPMSContinuousFeedbackInput:
      type: object
      description: Input schema for creating/updating feedback
      required:
      - receiver_id
      - subject
      - content
      properties:
        receiver_id:
          type: integer
          example: 456
        subject:
          type: string
          minLength: 1
          maxLength: 255
          example: Great work on the project
        content:
          type: string
          minLength: 1
          maxLength: 2000
          example: Your presentation was excellent and well-received by the team.
        feedback_type:
          type: string
          enum:
          - praise
          - constructive
          - goal_progress
          - general
          default: praise
          example: praise
        visibility:
          type: string
          enum:
          - private
          - public
          - manager_only
          default: private
          example: private
        is_anonymous:
          type: boolean
          default: false
          example: false
    EPMSContinuousFeedbackStats:
      type: object
      description: Feedback statistics
      properties:
        given:
          type: object
          properties:
            total:
              type: integer
              example: 25
            by_type:
              type: object
              properties:
                praise:
                  type: integer
                  example: 15
                constructive:
                  type: integer
                  example: 5
                recognition:
                  type: integer
                  example: 3
                coaching:
                  type: integer
                  example: 2
        received:
          type: object
          properties:
            total:
              type: integer
              example: 18
            pending_acknowledgment:
              type: integer
              example: 3
            acknowledged:
              type: integer
              example: 15
    EPMSFeedbackPickerUser:
      type: object
      description: |
        Lightweight user object returned by the feedback user-picker endpoint.
        Used for selecting recipients (giving mode) or viewing feedback targets (receiving mode).
      properties:
        id:
          type: integer
          description: User's internal ID
          example: 456
        name:
          type: string
          description: User's full name
          example: Jane Smith
        email:
          type: string
          format: email
          nullable: true
          description: User's email address
          example: jane.smith@company.com
        job_title:
          type: string
          nullable: true
          description: 'The job title this business shows for the person: their membership''s
            title (set by the profile''s Job Title select), else the profile''s own
            title.'
          example: Senior Engineer
        department:
          type: string
          nullable: true
          description: User's department name
          example: Engineering
        avatar_url:
          type: string
          format: uri
          nullable: true
          description: Full-size profile photo URL (200×200 px)
          example: https://example.com/photos/jane-200x200.jpg
        avatar_thumbnail_url:
          type: string
          format: uri
          nullable: true
          description: Thumbnail profile photo URL (40×40 px)
          example: https://example.com/photos/jane-40x40.jpg
      required:
      - id
      - name
    EPMSDevelopmentPlan:
      type: object
      description: Employee development plan
      properties:
        id:
          type: integer
          example: 456
        title:
          type: string
          example: Leadership Development Plan
        description:
          type: string
          nullable: true
          example: Focus on developing leadership skills
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - cancelled
          example: active
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_completion_date:
          type: string
          format: date
          nullable: true
          example: '2026-12-31'
        plan_type:
          type: string
          nullable: true
          example: leadership
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        manager:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        goals:
          type: array
          items:
            "$ref": "#/components/schemas/EPMSGoal"
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
    EPMSDevelopmentPlanInput:
      type: object
      description: Input schema for creating/updating development plans
      required:
      - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Leadership Development Plan
        description:
          type: string
          maxLength: 2000
          example: Focus on developing leadership skills
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - cancelled
          example: active
        start_date:
          type: string
          format: date
          example: '2026-01-01'
        target_completion_date:
          type: string
          format: date
          nullable: true
          example: '2026-12-31'
        employee_id:
          type: integer
          example: 456
        plan_type:
          type: string
          example: leadership
    EPMSDevelopmentGoalInput:
      type: object
      description: Input schema for development plan goals
      required:
      - title
      - target_date
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Complete Leadership Training
        description:
          type: string
          maxLength: 2000
          example: Finish the leadership certification program
        goal_type:
          type: string
          enum:
          - skill
          - experience
          - education
          - certification
          - project
          example: skill
        status:
          type: string
          enum:
          - not_started
          - in_progress
          - completed
          - cancelled
          example: not_started
        target_date:
          type: string
          format: date
          example: '2026-06-30'
        progress_percentage:
          type: number
          minimum: 0
          maximum: 100
          default: 0
          example: 0
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          example: high
    EPMSMeeting:
      type: object
      description: One-on-one meeting or check-in
      properties:
        id:
          type: integer
          example: 567
        title:
          type: string
          example: Q1 Check-in
        meeting_type:
          type: string
          enum:
          - performance_review
          - goal_discussion
          - feedback_session
          - check_in
          - development_planning
          - career_discussion
          example: check_in
        status:
          type: string
          enum:
          - scheduled
          - confirmed
          - in_progress
          - completed
          - cancelled
          - rescheduled
          example: scheduled
        scheduled_at:
          type: string
          format: date-time
          example: '2026-02-15T10:00:00Z'
        duration_minutes:
          type: integer
          example: 30
        location:
          type: string
          nullable: true
          example: Conference Room A
        description:
          type: string
          nullable: true
          example: Quarterly performance check-in
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        manager:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 789
            name:
              type: string
              example: Jane Manager
        notes:
          type: string
          nullable: true
          example: Great discussion about goals and progress
        completed_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-02-15T10:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T10:00:00Z'
    EPMSMeetingInput:
      type: object
      description: Input schema for creating/updating meetings
      required:
      - title
      - meeting_type
      - employee_id
      - scheduled_at
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          example: Q1 Check-in
        meeting_type:
          type: string
          enum:
          - performance_review
          - goal_discussion
          - feedback_session
          - check_in
          - development_planning
          - career_discussion
          example: check_in
        employee_id:
          type: integer
          example: 456
        scheduled_at:
          type: string
          format: date-time
          example: '2026-02-15T10:00:00Z'
        duration_minutes:
          type: integer
          default: 30
          example: 30
        location:
          type: string
          maxLength: 255
          example: Conference Room A
        description:
          type: string
          maxLength: 2000
          example: Quarterly performance check-in
    EPMSCompetencyFramework:
      type: object
      description: Competency framework with defined competencies
      properties:
        id:
          type: integer
          example: 678
        name:
          type: string
          example: Leadership Competencies
        description:
          type: string
          nullable: true
          example: Core leadership competencies for management roles
        framework_type:
          type: string
          enum:
          - core
          - leadership
          - technical
          - functional
          - role_specific
          example: leadership
        competencies:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Communication
              description:
                type: string
                nullable: true
                example: Ability to communicate effectively
              level:
                type: string
                enum:
                - beginner
                - intermediate
                - advanced
                - expert
                nullable: true
        created_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-01T10:00:00Z'
    EPMSCompetencyAssessment:
      type: object
      description: Competency assessment with ratings
      properties:
        id:
          type: integer
          example: 789
        employee:
          type: object
          properties:
            id:
              type: integer
              example: 456
            name:
              type: string
              example: John Doe
        competency_framework:
          type: object
          properties:
            id:
              type: integer
              example: 678
            name:
              type: string
              example: Leadership Competencies
        assessment_type:
          type: string
          enum:
          - self_assessment
          - manager_assessment
          - peer_assessment
          - 360_assessment
          example: manager_assessment
        status:
          type: string
          enum:
          - draft
          - in_progress
          - completed
          example: completed
        assessed_at:
          type: string
          format: date
          example: '2026-01-15'
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-01-31'
        overall_score:
          type: number
          nullable: true
          minimum: 0
          maximum: 5
          example: 4.2
        overall_comments:
          type: string
          nullable: true
          example: Strong performance across all competencies
        competency_ratings:
          type: array
          items:
            type: object
            properties:
              competency_id:
                type: integer
                example: 1
              competency_name:
                type: string
                example: Communication
              rating_value:
                type: number
                minimum: 0
                maximum: 5
                example: 4.5
              comments:
                type: string
                nullable: true
                example: Excellent communication skills
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-01-15T14:30:00Z'
    EPMSCompetencyAssessmentInput:
      type: object
      description: Input schema for creating/updating competency assessments
      required:
      - employee_id
      - competency_framework_id
      properties:
        employee_id:
          type: integer
          example: 456
        competency_framework_id:
          type: integer
          example: 678
        assessment_type:
          type: string
          enum:
          - self_assessment
          - manager_assessment
          - peer_assessment
          - 360_assessment
          example: manager_assessment
        assessed_at:
          type: string
          format: date
          example: '2026-01-15'
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-01-31'
        overall_score:
          type: number
          minimum: 0
          maximum: 5
          example: 4.2
        overall_comments:
          type: string
          maxLength: 2000
          example: Strong performance across all competencies
        status:
          type: string
          enum:
          - draft
          - in_progress
          - completed
          example: completed
        competency_ratings_attributes:
          type: array
          items:
            type: object
            required:
            - competency_id
            - rating_value
            properties:
              competency_id:
                type: integer
                example: 1
              rating_value:
                type: number
                minimum: 0
                maximum: 5
                example: 4.5
              comments:
                type: string
                maxLength: 1000
                example: Excellent communication skills
    FormSubmission:
      type: object
      description: Form submission data and metadata
      properties:
        id:
          type: integer
          description: Unique submission ID
          example: 789
        status:
          type: string
          enum:
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          - pending_completion
          - changes_requested
          description: Submission status
          example: submitted
        display_state:
          type: string
          enum:
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          - pending_completion
          - changes_requested
          - completed
          description: |
            What the Forms pages call it. `completed` is a sent submission on a form that
            needs no approval (its `status` stays `submitted`); otherwise the same as `status`.
          example: completed
        status_label:
          type: string
          description: Human-readable label of `display_state`, as the pages' status
            badge prints it
          example: Completed
        completion_percentage:
          type: number
          minimum: 0
          maximum: 100
          description: Share of the form's questions answered (optional ones left
            blank keep it under 100) — not progress toward Completed; the pages label
            it "Answered"
          example: 100
        submission_data:
          type: object
          description: Form field values
          additionalProperties: true
          example:
            incident_type: injury
            description: Employee slipped on wet floor
            severity: minor
        submitted_at:
          type: string
          format: date-time
          description: Submission timestamp
          nullable: true
          example: '2024-01-15T14:30:00Z'
        created_at:
          type: string
          format: date-time
          description: Creation timestamp
          example: '2024-01-15T14:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: Last update timestamp
          example: '2024-01-15T14:30:00Z'
        files:
          type: array
          description: Files uploaded with this submission
          items:
            type: object
            properties:
              id:
                type: integer
                example: 12
              field_name:
                type: string
                description: Name of the form field the file belongs to
                example: incident_photo
              file_type:
                type: string
                description: Detected file type (image, video, audio, pdf, document,
                  unknown)
                example: image
              filename:
                type: string
                description: Original filename
                example: photo.jpg
              url:
                type: string
                description: URL to download/view the file
                example: https://example.com/files/12
        device_info:
          type: object
          description: Device metadata captured at submission time
          additionalProperties: true
          nullable: true
        is_offline_submission:
          type: boolean
          description: Whether the submission was created from offline mode
          example: false
      required:
      - id
      - status
      - submission_data
      - completion_percentage
    ReviewedSubmission:
      allOf:
      - type: object
        description: Form submission data and metadata
        properties:
          id:
            type: integer
            description: Unique submission ID
            example: 789
          status:
            type: string
            enum:
            - draft
            - submitted
            - under_review
            - approved
            - rejected
            - pending_completion
            - changes_requested
            description: Submission status
            example: submitted
          display_state:
            type: string
            enum:
            - draft
            - submitted
            - under_review
            - approved
            - rejected
            - pending_completion
            - changes_requested
            - completed
            description: |
              What the Forms pages call it. `completed` is a sent submission on a form that
              needs no approval (its `status` stays `submitted`); otherwise the same as `status`.
            example: completed
          status_label:
            type: string
            description: Human-readable label of `display_state`, as the pages' status
              badge prints it
            example: Completed
          completion_percentage:
            type: number
            minimum: 0
            maximum: 100
            description: Share of the form's questions answered (optional ones left
              blank keep it under 100) — not progress toward Completed; the pages
              label it "Answered"
            example: 100
          submission_data:
            type: object
            description: Form field values
            additionalProperties: true
            example:
              incident_type: injury
              description: Employee slipped on wet floor
              severity: minor
          submitted_at:
            type: string
            format: date-time
            description: Submission timestamp
            nullable: true
            example: '2024-01-15T14:30:00Z'
          created_at:
            type: string
            format: date-time
            description: Creation timestamp
            example: '2024-01-15T14:00:00Z'
          updated_at:
            type: string
            format: date-time
            description: Last update timestamp
            example: '2024-01-15T14:30:00Z'
          files:
            type: array
            description: Files uploaded with this submission
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 12
                field_name:
                  type: string
                  description: Name of the form field the file belongs to
                  example: incident_photo
                file_type:
                  type: string
                  description: Detected file type (image, video, audio, pdf, document,
                    unknown)
                  example: image
                filename:
                  type: string
                  description: Original filename
                  example: photo.jpg
                url:
                  type: string
                  description: URL to download/view the file
                  example: https://example.com/files/12
          device_info:
            type: object
            description: Device metadata captured at submission time
            additionalProperties: true
            nullable: true
          is_offline_submission:
            type: boolean
            description: Whether the submission was created from offline mode
            example: false
        required:
        - id
        - status
        - submission_data
        - completion_percentage
      - "$ref": "#/components/schemas/FormSubmissionDecisionCapabilities"
      - type: object
        properties:
          can_approve:
            type: boolean
            description: May this caller approve it now — false after an intermediate
              workflow step they signed.
          status_label:
            type: string
            description: Human-readable status label
            example: Approved
          reviewed_at:
            type: string
            format: date-time
            nullable: true
            description: When the review decision was taken
            example: '2026-06-24T14:30:00Z'
          review_round:
            type: integer
            description: |
              Which correction cycle the submission is on. 0 until the first
              field-level return; incremented by every
              `POST /form_submissions/{id}/return_fields`. Compare against a
              field's `field_review.round` to tell a current return from a
              historical one.
            example: 2
          review_notes:
            type: string
            nullable: true
            description: Reviewer notes (the rejection reason for reject)
            example: Approved — all required documents attached.
          reviewed_by:
            type: object
            nullable: true
            description: The reviewer who took the decision
            properties:
              id:
                type: integer
                example: 7
              name:
                type: string
                example: Anup Patel
    FormSubmissionDecisionCapabilities:
      type: object
      properties:
        can_reject:
          type: boolean
          description: May decide it while it awaits a decision, with a token carrying
            `destructive:forms`.
        can_return_fields:
          type: boolean
          description: |
            May decide it and there is someone to return it to — not an anonymous
            submission, not a submitter who has left, and a customer's answers to a
            project's form request only while the request is open to them.
        return_fields_refusal:
          type: string
          nullable: true
          description: Why Return is off for someone who may decide it; null otherwise.
        can_withdraw:
          type: boolean
          description: The submitter may withdraw it (`POST /form_submissions/{id}/withdraw`).
        withdraw_refusal:
          type: string
          nullable: true
          description: Why its submitter can't withdraw it while it waits for review;
            null otherwise.
        can_reopen:
          type: boolean
          description: May reopen the approval for review (`POST /form_submissions/{id}/reopen`).
        reopen_refusal:
          type: string
          nullable: true
          description: Why an approved submission can't be reopened, to someone who
            may decide it; null otherwise.
        can_take_back_returns:
          type: boolean
          description: May take back its return for correction (`POST /form_submissions/{id}/take_back_returns`).
        take_back_returns_refusal:
          type: string
          nullable: true
          description: Why a returned submission's return can't be taken back, to
            someone who manages it; null otherwise.
    SubmissionTimelineEvent:
      type: object
      description: A single event on the submission status timeline
      properties:
        type:
          type: string
          enum:
          - submitted
          - approved
          - changes_requested
          - rejected
          - withdrawn
          - reopened_for_review
          - returns_taken_back
          description: Machine-readable event kind
        title:
          type: string
          description: Human-readable event title
          example: Rejected
        state:
          type: string
          enum:
          - done
          - warning
          - bad
          description: Visual marker state — done (success/neutral), warning (returned
            for correction), bad (rejected)
        at:
          type: string
          format: date-time
          nullable: true
          description: When the event occurred; null for `withdrawn`, which keeps
            no time
        by:
          type: object
          nullable: true
          description: Who performed the event (the reviewer; the sender for `withdrawn`);
            null for the submitted event
          properties:
            id:
              type: integer
            name:
              type: string
              example: Reva Yu
        note:
          type: string
          nullable: true
          description: Reviewer's note for the outcome (e.g. rejection reason), or
            the reason given for a reopen or a return taken back; null when none
          example: Please retake photo 2.
      required:
      - type
      - title
      - state
      - at
    LeaveRequest:
      type: object
      description: Basic leave request information
      properties:
        id:
          type: integer
          description: Unique leave request ID
          example: 123
        user_id:
          type: integer
          description: ID of the user who created the request
          example: 456
        leave_type:
          "$ref": "#/components/schemas/LeaveTypeBasic"
        start_date:
          type: string
          format: date
          description: Start date of leave
          example: '2024-03-15'
        end_date:
          type: string
          format: date
          description: End date of leave
          example: '2024-03-17'
        hours_calculated:
          type: number
          description: Total hours for this leave request (primary field)
          example: 24
        business_days:
          type: number
          description: 'DEPRECATED: Use hours_calculated instead. Returns same value
            as hours_calculated for backward compatibility.'
          example: 24
        status:
          type: string
          enum:
          - pending
          - approved
          - denied
          - cancelled
          - special_approval
          description: Current status of the leave request
          example: pending
        notes:
          type: string
          nullable: true
          description: Optional notes for the leave request
          example: Family vacation
        created_at:
          type: string
          format: date-time
          description: When the request was created
          example: '2024-02-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: When the request was last updated
          example: '2024-02-15T10:00:00Z'
      required:
      - id
      - user_id
      - leave_type
      - start_date
      - end_date
      - hours_calculated
      - status
      - created_at
      - updated_at
    LeaveRequestDetailed:
      allOf:
      - "$ref": "#/components/schemas/LeaveRequest"
      - type: object
        properties:
          approval_details:
            type: object
            description: Approval workflow information
            properties:
              requires_approval:
                type: boolean
                description: Whether this request requires approval
                example: true
              approved_by:
                type: object
                nullable: true
                description: User who approved the request
                properties:
                  id:
                    type: integer
                    example: 789
                  name:
                    type: string
                    example: Jane Manager
              approved_at:
                type: string
                format: date-time
                nullable: true
                description: When the request was approved
                example: '2024-02-16T14:30:00Z'
              denial_reason:
                type: string
                nullable: true
                description: Reason for denial if applicable
                example: Insufficient coverage during requested period
          conflicts:
            type: object
            description: Shift conflict information
            properties:
              has_conflicts:
                type: boolean
                description: Whether this request conflicts with assigned shifts
                example: false
              conflicting_shifts:
                type: array
                description: List of conflicting shifts
                items:
                  "$ref": "#/components/schemas/ShiftConflict"
          blackout_warnings:
            type: array
            description: Blackout period warnings
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                name:
                  type: string
                  example: Winter Holiday Season
                start_date:
                  type: string
                  format: date
                  example: '2024-12-15'
                end_date:
                  type: string
                  format: date
                  example: '2024-12-31'
                blocks_requests:
                  type: boolean
                  example: true
                message:
                  type: string
                  example: Leave requests are blocked during Winter Holiday Season
          coverage_impact:
            type: object
            nullable: true
            description: Coverage impact analysis
            properties:
              affects_coverage:
                type: boolean
                example: false
              coverage_percentage:
                type: number
                example: 85.5
              available_spots:
                type: integer
                example: 3
              message:
                type: string
                example: This request will not affect coverage limits
    LeaveBalance:
      type: object
      description: Leave balance information for a specific leave type. All numeric
        values are in HOURS.
      properties:
        leave_type_id:
          type: integer
          description: ID of the leave type
          example: 1
        leave_type:
          "$ref": "#/components/schemas/LeaveTypeBasic"
        year:
          type: integer
          description: Year for this balance
          example: 2024
        unit:
          type: string
          description: Unit of measurement for balance values
          example: hours
          enum:
          - hours
        accrued:
          type: number
          description: Total hours accrued for the year
          example: 120.0
        used:
          type: number
          description: Hours already used
          example: 40.0
        balance:
          type: number
          description: Available hours remaining
          example: 80.0
        pending:
          type: number
          description: Hours in pending requests
          example: 24.0
        percentages:
          type: object
          description: Usage percentages for progress indicators
          properties:
            used:
              type: number
              description: Percentage of accrued hours used
              example: 33.3
            pending:
              type: number
              description: Percentage of accrued hours pending
              example: 20.0
            available:
              type: number
              description: Percentage of accrued hours available
              example: 46.7
        source:
          "$ref": "#/components/schemas/ManagedSource"
      required:
      - leave_type_id
      - leave_type
      - year
      - accrued
      - used
      - balance
      - pending
      - percentages
    ManagedSource:
      type: object
      nullable: true
      description: Null for a record Workforce keeps itself. For a leave balance or
        timesheet mirrored from the workforce management system of record (UKG), it
        is read-only in Workforce and changed in that system.
      properties:
        managed_by:
          type: string
          description: The system's name as people see it ("Managed in UKG")
          example: UKG
        as_of:
          type: string
          format: date-time
          nullable: true
          description: When the system last reported this record
        stale:
          type: boolean
          description: True when that was too long ago to trust (36 hours) for a record
            the system still sends; a closed pay period's timesheet or a past year's
            balance is final, never stale
        fix_url:
          type: string
          nullable: true
          description: The system's own address, where the record is corrected; null
            when not configured
        read_only:
          type: boolean
          example: true
        approved_not_reflected_hours:
          type: number
          description: Leave balances only. Hours of leave approved in Workforce,
            starting on or after as_of, that the system's balance does not show yet.
            The balance itself is the system's, unchanged; these hours are already
            counted against what is left to book.
          example: 16.0
        signed_off:
          type: boolean
          description: Timesheets only. Whether the system has signed off this pay
            period's timecard.
        signed_off_at:
          type: string
          format: date-time
          nullable: true
          description: Timesheets only. When the system signed it off, as first reported.
    LeaveBalanceDetailed:
      allOf:
      - "$ref": "#/components/schemas/LeaveBalance"
      - type: object
        properties:
          recent_usage:
            type: array
            description: Leave consumed against this balance in the last 6 months,
              newest first. Includes approved leave requests, usage an administrator
              recorded by hand, and hours paid out from banked time — all three move
              the balance's `used` figure. Pending requests are NOT included (they
              are not usage yet); read the balance's `pending` hours for those.
            items:
              type: object
              properties:
                id:
                  type: integer
                  description: Identifier within the table named by `record_type`.
                  example: 123
                record_type:
                  type: string
                  description: Which record `id` refers to. `leave_request` for an
                    approved request; `leave_ledger_entry` for administrator- recorded
                    usage or a banked-time payout.
                  enum:
                  - leave_request
                  - leave_ledger_entry
                  example: leave_request
                source:
                  type: string
                  enum:
                  - leave_request
                  - manual
                  - payout
                  example: leave_request
                start_date:
                  type: string
                  format: date
                  example: '2024-01-15'
                end_date:
                  type: string
                  format: date
                  example: '2024-01-17'
                hours:
                  type: number
                  description: Hours actually charged to the balance (the paid portion).
                    Use this figure to reconcile against `used`.
                  example: 16
                unpaid_hours:
                  type: number
                  description: Portion taken as unpaid leave, which never touches
                    the balance.
                  example: 8
                hours_calculated:
                  type: number
                  description: Total hours on the request, including any unpaid portion.
                    Equals `hours` unless the request has an unpaid split.
                  example: 24
                business_days:
                  type: number
                  description: 'DEPRECATED: Use hours_calculated'
                  example: 24
                status:
                  type: string
                  nullable: true
                  description: Always `approved` for a leave request; null for ledger
                    entries.
                  example: approved
                upcoming:
                  type: boolean
                  description: Approved but the time off has not happened yet.
                  example: false
                counts_toward_balance:
                  type: boolean
                  description: False for a historical record imported alongside the
                    balance figures its hours are already inside. Such rows are listed
                    but excluded from `used`.
                  example: true
                notes:
                  type: string
                  nullable: true
                  example: Vacation
                edited:
                  type: boolean
                  description: The leave request has been modified since it was submitted.
                  example: false
                edit_count:
                  type: integer
                  example: 0
                last_edited_at:
                  type: string
                  format: date-time
                  nullable: true
                  example: '2024-02-01T09:30:00Z'
          accrual_policy:
            type: object
            nullable: true
            description: Accrual policy information
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Standard Vacation Policy
              accrual_rate:
                type: number
                description: Hours accrued per period
                example: 3.08
              accrual_unit:
                type: string
                description: Unit for all accrual values
                example: hours
                enum:
                - hours
              accrual_frequency:
                type: string
                example: bi-weekly
              max_balance:
                type: number
                description: Maximum balance cap in hours
                nullable: true
                example: 120.0
              max_accrual:
                type: number
                description: Deprecated, use max_balance
                nullable: true
                example: 120.0
              carryover_allowed:
                type: boolean
                example: true
              carryover_max:
                type: number
                description: Maximum hours to carry over
                nullable: true
                example: 40.0
              waiting_period_days:
                type: integer
                description: Calendar days before accrual starts
                example: 90
              proration_enabled:
                type: boolean
                example: true
          projected_balance:
            type: number
            description: Projected balance (hours) at end of year
            example: 100.0
    LeaveBalanceSummary:
      type: object
      description: Comprehensive leave balance summary
      properties:
        totals:
          type: object
          description: Totals across all leave types
          properties:
            accrued:
              type: number
              example: 25.0
            used:
              type: number
              example: 8.0
            pending:
              type: number
              example: 3.0
            available:
              type: number
              example: 14.0
            usage_percentage:
              type: number
              example: 32.0
        by_leave_type:
          type: array
          description: Balances by leave type
          items:
            "$ref": "#/components/schemas/LeaveBalance"
        upcoming_leave:
          type: array
          description: Upcoming approved leave
          items:
            type: object
            properties:
              id:
                type: integer
                example: 123
              leave_type:
                type: object
                properties:
                  id:
                    type: integer
                    example: 1
                  name:
                    type: string
                    example: Vacation
                  color:
                    type: string
                    example: "#4CAF50"
              start_date:
                type: string
                format: date
                example: '2024-04-15'
              end_date:
                type: string
                format: date
                example: '2024-04-17'
              hours:
                type: number
                description: Hours that will be charged to the balance (the paid portion).
                example: 16
              unpaid_hours:
                type: number
                description: Portion to be taken as unpaid leave, which never touches
                  the balance.
                example: 8
              hours_calculated:
                type: number
                description: Total hours on the request, including any unpaid portion.
                  Equals `hours` unless the request has an unpaid split.
                example: 24
              business_days:
                type: number
                description: 'DEPRECATED: Use hours_calculated'
                example: 24
              notes:
                type: string
                nullable: true
                example: Spring vacation
        year:
          type: integer
          description: Year for this summary
          example: 2024
      required:
      - totals
      - by_leave_type
      - upcoming_leave
      - year
    LeaveType:
      type: object
      description: Leave type information
      properties:
        id:
          type: integer
          description: Unique leave type ID
          example: 1
        name:
          type: string
          description: Name of the leave type
          example: Vacation
        color:
          type: string
          description: Color code for UI display
          example: "#4CAF50"
        icon:
          type: string
          description: Icon class for UI display
          example: fas fa-calendar-star
        active:
          type: boolean
          description: Whether this leave type is active
          example: true
        default:
          type: boolean
          description: Whether this is the default leave type
          example: false
        current_balance:
          type: object
          nullable: true
          description: Current balance for this leave type (if requested). Hour figures
            are strings with two decimals.
          properties:
            accrued:
              type: number
              example: 15.0
            used:
              type: number
              example: 5.0
            balance:
              type: number
              example: 10.0
            balance_hours:
              type: string
              description: The ledger balance (accrued − used). It still counts hours
                waiting for a decision — show available_to_request_hours as what a
                new request can use.
              example: '10.00'
            pending_hours:
              type: string
              description: Hours in this type's requests that are waiting for a decision.
              example: '4.00'
            available_to_request_hours:
              type: string
              description: Hours a new request can draw today — the ledger balance
                less pending requests, the figure the server books a request against
                (the request form's "hours left to request").
              example: '6.00'
            year:
              type: integer
              example: 2024
      required:
      - id
      - name
      - color
      - icon
      - active
      - default
    UserAvailability:
      type: object
      description: User availability block information
      properties:
        id:
          type: integer
          description: Unique availability ID
          example: 123
        specific_date:
          type: string
          format: date
          description: Date for this availability block
          example: '2025-10-15'
        start_time:
          type: string
          format: time
          description: Start time (displayed in user's timezone)
          example: '09:00'
        end_time:
          type: string
          format: time
          description: End time (displayed in user's timezone)
          example: '17:00'
        notes:
          type: string
          nullable: true
          description: Optional notes about this availability
          example: Available for morning shift
        store_in_utc:
          type: boolean
          description: Whether times are stored in UTC
          example: true
        created_at:
          type: string
          format: date-time
          description: When this availability was created
          example: '2025-10-01T10:30:00Z'
        updated_at:
          type: string
          format: date-time
          description: When this availability was last updated
          example: '2025-10-01T10:30:00Z'
      required:
      - id
      - specific_date
      - start_time
      - end_time
    WeeklyAvailabilityConfirmation:
      type: object
      description: Weekly availability confirmation information
      properties:
        id:
          type: integer
          description: Unique confirmation ID
          example: 456
        start_date:
          type: string
          format: date
          description: Week start date
          example: '2025-10-13'
        end_date:
          type: string
          format: date
          description: Week end date
          example: '2025-10-19'
        confirmed_at:
          type: string
          format: date-time
          description: When the availability was confirmed
          example: '2025-10-12T15:30:00Z'
        notes:
          type: string
          nullable: true
          description: Optional notes about the confirmation
          example: Confirmed for this week
        auto_generated:
          type: boolean
          description: Whether this was auto-generated by the system
          example: false
        created_at:
          type: string
          format: date-time
          description: When this confirmation was created
          example: '2025-10-12T15:30:00Z'
        updated_at:
          type: string
          format: date-time
          description: When this confirmation was last updated
          example: '2025-10-12T15:30:00Z'
      required:
      - id
      - start_date
      - end_date
      - confirmed_at
      - auto_generated
    LeaveTypeBasic:
      type: object
      description: Basic leave type information for references
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: Vacation
        color:
          type: string
          example: "#4CAF50"
        icon:
          type: string
          example: fas fa-calendar-star
      required:
      - id
      - name
      - color
      - icon
    LeaveTypeDetailed:
      allOf:
      - "$ref": "#/components/schemas/LeaveType"
      - type: object
        properties:
          description:
            type: string
            nullable: true
            description: Description of the leave type
            example: Annual vacation leave for rest and recreation
          advance_notice_days:
            type: integer
            description: Required advance notice in days
            example: 7
          documentation_required:
            type: boolean
            description: Whether documentation is required
            example: false
          documentation_threshold_days:
            type: integer
            description: Threshold for requiring documentation
            example: 3
          requires_approval:
            type: boolean
            description: Whether requests require approval
            example: true
          deduct_from_balance:
            type: boolean
            description: Whether to deduct from balance
            example: true
          allow_negative_balance:
            type: boolean
            description: Whether negative balances are allowed
            example: false
          accrual_enabled:
            type: boolean
            description: Whether accrual is enabled
            example: true
          accrual_policy:
            type: object
            nullable: true
            description: Associated accrual policy
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Standard Vacation Policy
              accrual_rate:
                type: number
                example: 1.25
              accrual_frequency:
                type: string
                example: monthly
              accrual_frequency_display:
                type: string
                example: Monthly
              max_accrual:
                type: number
                nullable: true
                example: 30.0
              carryover_allowed:
                type: boolean
                example: true
              carryover_max:
                type: number
                nullable: true
                example: 5.0
              waiting_period_days:
                type: integer
                example: 90
              proration_enabled:
                type: boolean
                example: true
          business_rules:
            type: object
            description: Business rules and policies
            properties:
              advance_notice_required:
                type: boolean
                example: true
              documentation_rules:
                type: object
                properties:
                  required:
                    type: boolean
                    example: false
                  threshold_days:
                    type: integer
                    example: 3
              approval_workflow:
                type: object
                properties:
                  requires_approval:
                    type: boolean
                    example: true
                  auto_approve_threshold:
                    type: number
                    nullable: true
                    example:
              balance_rules:
                type: object
                properties:
                  deduct_from_balance:
                    type: boolean
                    example: true
                  allow_negative:
                    type: boolean
                    example: false
    ShiftConflict:
      type: object
      description: Information about a conflicting shift
      properties:
        id:
          type: integer
          description: Shift ID
          example: 789
        name:
          type: string
          description: Shift name
          example: Morning Shift
        start_time:
          type: string
          format: date-time
          description: Shift start time
          example: '2024-03-15T08:00:00Z'
        end_time:
          type: string
          format: date-time
          description: Shift end time
          example: '2024-03-15T16:00:00Z'
        location:
          type: object
          nullable: true
          description: Shift location
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Main Office
      required:
      - id
      - name
      - start_time
      - end_time
    PaginationMeta:
      type: object
      description: Pagination metadata
      properties:
        total_count:
          type: integer
          description: Total number of items
          example: 150
        total_pages:
          type: integer
          description: Total number of pages
          example: 6
        current_page:
          type: integer
          description: Current page number
          example: 1
        per_page:
          type: integer
          description: Items per page
          example: 25
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
    FormSummaryItem:
      type: object
      description: A published form as returned by GET /api/v1/forms
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: Daily Safety Checklist
        description:
          type: string
          nullable: true
          example: Complete before each shift.
        category:
          type: string
          example: safety
        priority:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          example: high
        field_count:
          type: integer
          example: 12
        required_field_count:
          type: integer
          example: 8
        page_count:
          type: integer
          description: Number of pages in the form (page_break fields + 1)
          example: 3
        estimated_time_minutes:
          type: integer
          example: 9
        has_file_uploads:
          type: boolean
          example: false
        sharing_enabled:
          type: boolean
          example: true
        requires_approval:
          type: boolean
          example: false
        accepting_submissions:
          type: boolean
          description: 'Whether the form is currently open for new responses. `false`
            when it passed its scheduled close time, reached its response cap, or
            is not published — exactly the states in which `GET /api/v1/forms/{id}`
            and `POST /api/v1/forms/{id}/submit` answer 403 `form_not_available` for
            a user who is not an admin, forms admin, or the form''s creator. Badge
            or disable the row instead of opening it.

            '
          example: true
        closed_reason:
          type: string
          nullable: true
          description: 'Short label naming WHY the form is not accepting responses,
            or `null` when `accepting_submissions` is true. Same vocabulary the web
            Forms to Fill Out list and the mobile cards badge with.

            '
          enum:
          - Closed
          - Response limit reached
          - Archived
          - Not published
          example:
        created_at:
          type: string
          format: date-time
          example: '2026-01-15T09:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-04-10T14:22:00Z'
      required:
      - id
      - name
      - category
      - priority
      - field_count
      - required_field_count
      - page_count
      - estimated_time_minutes
      - has_file_uploads
      - sharing_enabled
      - requires_approval
      - accepting_submissions
      - closed_reason
      - created_at
      - updated_at
    FormCategoryItem:
      type: object
      description: A form category as returned by GET /api/v1/forms/categories
      properties:
        value:
          type: string
          description: Category slug — pass verbatim to GET /api/v1/forms?category=<value>
          example: safety
        label:
          type: string
          description: Human-readable category name
          example: Safety
        form_count:
          type: integer
          description: Number of published forms in this category for the caller's
            business
          example: 2
      required:
      - value
      - label
      - form_count
    ApprovalItem:
      type: object
      description: A submission in the reviewer's Approvals queue (GET /api/v1/forms/approvals)
      properties:
        id:
          type: integer
          example: 2231
        reference:
          type: string
          description: Display reference ("#<id>")
          example: "#2231"
        form_id:
          type: integer
          example: 7
        form_name:
          type: string
          example: Site Safety Audit
        form_description:
          type: string
          nullable: true
          example: Quarterly site-level safety walkthrough
        form_category:
          type: string
          example: safety
        priority:
          type: string
          enum:
          - urgent
          - high
          - normal
          - low
          description: Form priority — drives the urgency chip
          example: high
        submitter:
          type: string
          description: Submitter's display name (or "Anonymous User")
          example: J. Rivera
        submitter_email:
          type: string
          nullable: true
          example: jrivera@example.com
        submitter_photo_url:
          type: string
          nullable: true
          description: Thumbnail avatar URL for the submitter; null for anonymous
            submissions
          example: https://officechat-dev.workforce.mangoapps.com/rails/active_storage/representations/.../avatar.jpg
        status:
          type: string
          enum:
          - submitted
          - under_review
          example: under_review
        status_label:
          type: string
          example: Under review
        requires_approval:
          type: boolean
          example: true
        overdue:
          type: boolean
          description: |
            True when this row has been awaiting review longer than the server-side
            review SLA (currently 7 days on the `waiting_since` clock). Agrees
            row-for-row with the `?overdue=true` filter — badge from this rather
            than recomputing the threshold on the client, which would diverge the
            moment the server policy changed.
          example: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
        waiting_since:
          type: string
          format: date-time
          description: When the item entered the queue (submitted_at, falling back
            to created_at) — use for the "Waiting Nd" label
        updated_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
      required:
      - id
      - reference
      - form_id
      - form_name
      - form_category
      - priority
      - submitter
      - status
      - status_label
      - requires_approval
      - overdue
      - waiting_since
      - updated_at
      - created_at
    MySubmissionItem:
      type: object
      description: A single form submission as returned by GET /api/v1/forms/my_submissions
      properties:
        id:
          type: integer
          example: 1042
        form_id:
          type: integer
          example: 7
        form_name:
          type: string
          description: Used as the card title in the mobile list
          example: Equipment Transfer
        form_description:
          type: string
          nullable: true
          description: Used as the card subtitle
          example: Transfer equipment custody between sites
        form_category:
          type: string
          example: operations
        status:
          type: string
          enum:
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          - pending_completion
          - changes_requested
          example: draft
        display_state:
          type: string
          description: 'The state the web pages show: the stored status, except `completed`
            for a sent submission whose form needs no approval.'
          enum:
          - draft
          - submitted
          - under_review
          - approved
          - rejected
          - pending_completion
          - changes_requested
          - completed
          example: completed
        status_label:
          type: string
          description: Human-readable label for the status badge (of `display_state`)
          example: Saved draft
        completion_percentage:
          type: integer
          minimum: 0
          maximum: 100
          description: Used for the Progress meta line
          example: 85
        submitted_at:
          type: string
          format: date-time
          nullable: true
          description: null for drafts
          example:
        reviewed_at:
          type: string
          format: date-time
          nullable: true
          description: When a reviewer approved or rejected; null if not yet reviewed
          example:
        review_notes:
          type: string
          nullable: true
          description: Reviewer's notes on approval or rejection
          example:
        updated_at:
          type: string
          format: date-time
          description: Used for the "Last updated" meta line
          example: '2026-06-11T16:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2026-06-10T08:00:00Z'
        is_offline_submission:
          type: boolean
          example: false
      required:
      - id
      - form_id
      - form_name
      - form_category
      - status
      - status_label
      - completion_percentage
      - updated_at
      - created_at
      - is_offline_submission
    NewsFeedNotificationPreference:
      type: object
      description: One Communications notification preference of the caller, in this
        business.
      properties:
        segment_id:
          type: integer
          nullable: true
          description: null for the default preference
        operational_frequency:
          type: string
          nullable: true
          enum:
          - realtime
          - hourly
          - daily
          -
          description: The caller's own choice for announcements; null = none, the
            company default applies
        effective_operational_frequency:
          type: string
          enum:
          - realtime
          - hourly
          - daily
          description: What the caller actually gets for announcements
        social_frequency:
          type: string
          enum:
          - daily
          - weekly
        persisted:
          type: boolean
          description: false when nothing is saved yet and these are the defaults
            the caller gets
    CommsRequestedAction:
      type: object
      description: |
        The one thing a message (a feed post, a broadcast, a newsletter issue)
        asks THIS reader to do — a form to complete, a survey to take, a course
        or a policy to open — as the web draws it on the message ("Requested
        action: <title>" and a button). Carried as `requested_action`, `null`
        unless the message was sent to the caller with one: its author, a
        reader outside its audience and anyone it was not sent to get `null`.
        Draw one of three states: `completed` ("You have completed this
        action."), else `unavailable` (the requested version is no longer open
        to this reader — no button), else a button labelled by `kind` (`form`
        "Complete form", `survey` "Take survey", `course` "Open course",
        `policy` "Open policy") that opens `url`.
      properties:
        title:
          type: string
          nullable: true
          description: The form, survey, course or policy's name.
        kind:
          type: string
          enum:
          - form
          - survey
          - course
          - policy
        completed:
          type: boolean
        unavailable:
          type: boolean
        url:
          type: string
          nullable: true
          description: |
            Absolute URL of the page that completes the action — the phone
            page where it has one. Non-null only when the action is neither
            completed nor unavailable.
      required:
      - title
      - kind
      - completed
      - unavailable
      - url
      example:
        title: Ladder safety check
        kind: form
        completed: false
        unavailable: false
        url: https://acme.workforce.mangoapps.com/m/apps/forms/form/5/fill
    NewsFeedSummary:
      type: object
      description: List-card serialization of a feed post.
      properties:
        id:
          type: integer
        content_type:
          type: string
          enum:
          - update
          - question
          - poll
        content_category:
          type: string
          enum:
          - operational
          - company_news
          - social
          description: |
            What the post is about. `operational` and `social` are derived
            from `priority`; `company_news` is chosen by the author on an
            Operational or Must Read post (a result, a new customer, an office
            opening, an award) and gives the post its own Company News tab and
            filter. Never combined with `priority: social`.
        source:
          type: object
          nullable: true
          description: |
            Provenance for app-contributed posts (`NewsFeed::FeedPublisher.contribute`).
            `null` for ordinary human composer posts.

            **This — not `content_type` — is the feed-type discriminator.** A
            Broadcast fans out to its audience and then contributes a feed row
            with a hardcoded `content_type` of `update`, so a broadcast-originated
            post is indistinguishable from a normal post by `content_type` alone.
            Detect one with `source && source.type == "Broadcast"`.

            Backed by columns on the feed row (`source_type` / `source_id` /
            `source_event`), so it is N+1-safe on the list path and identical on
            the list and detail endpoints.
          properties:
            type:
              type: string
              description: |
                Source model name. Known contributors:
                  * `Broadcast`                — a published Broadcast
                  * `CommsHub::Issue`          — a sent newsletter issue
                  * `Livestreaming::LiveEvent` — a livestream recording
                  * `Contests::Contest`        — contest winners announced

                Treat the set as open — plugins contribute under their own
                model names. Match on the exact string; do not parse it.
              example: Broadcast
            id:
              type: integer
              description: Primary key of the source record, in that model's own table.
              example: 45
            event:
              type: string
              description: |
                The lifecycle moment that produced this post. Part of the
                contribution de-dup key, so one source can contribute at most
                one feed per event. Known values: `broadcast_published`,
                `issue_published`, `recording_available`, `winners_announced`.
              example: broadcast_published
            critical:
              type: boolean
              description: |
                Whether the originating record is marked critical (`Broadcast`
                only today) — the red **CRITICAL** badge. Derived from
                provenance rather than copied onto the row, so downgrading the
                broadcast clears the badge and entries published before this
                shipped light up too.
              example: true
            channels:
              type: array
              items:
                type: string
                enum:
                - in_app
                - email
                - sms
                - voice
                - push
              description: |
                **Broadcast-sourced entries only.** The channels the broadcast
                actually fanned out on — the card's "Delivered in-app · Push ·
                SMS · Voice" row. It varies per broadcast (the author's channel
                toggles), so it cannot be inferred; the feed row carries no
                channel of its own. `in_app` always delivers; a channel appears
                unless the author switched it off.

                Absent when the post is not broadcast-sourced, or the broadcast
                no longer exists.
              example:
              - in_app
              - email
              - sms
              - push
            requires_acknowledgement:
              type: boolean
              description: |
                **Broadcast-sourced entries only.** Whether the BROADCAST asks
                for an acknowledgement.

                The post's own top-level `requires_acknowledgement` is
                deliberately `false` on these entries: the broadcast contributes
                at priority `operational` and never as must-read, so
                acknowledgement stays on ONE ledger rather than being minted a
                second time on the post. A client rendering the **Acknowledge**
                button therefore reads THIS flag and posts to
                `POST /api/v1/broadcasts/{source.id}/acknowledge` — not the
                feed's acknowledge endpoint, which records against the wrong
                ledger.
              example: true
            acknowledged:
              type: boolean
              description: |
                **Broadcast-sourced entries only.** Whether the CALLING user has
                already acknowledged that broadcast.
              example: false
          required:
          - type
          - id
          - event
          example:
            type: Broadcast
            id: 617
            event: broadcast_published
            critical: true
            channels:
            - in_app
            - email
            - sms
            - push
            requires_acknowledgement: true
            acknowledged: false
        priority:
          type: string
          enum:
          - must_read
          - operational
          - social
        is_announcement:
          type: boolean
          description: |
            Whether this post is the composer's **Announcement** kind.

            The Communications composer is one authoring surface with an
            intensity dial, and `priority` is how the chosen kind is stored:
            `social` → post, `operational` → **announcement**, `must_read` →
            must-read (a Broadcast is its own record, surfaced here via
            `source.type == "Broadcast"`). This flag is `priority ==
            "operational"` on a composed post, returned as a boolean alongside
            `must_read` so a client can render the Announcement badge without
            hardcoding that priority→kind table. Reading `priority` directly
            still works and is unchanged.

            Present on the list and detail endpoints alike, for every
            `content_type` (update / question / poll).

            A broadcast-contributed post is stored at priority `operational`
            but is a Broadcast to its readers, so it reports `false` here
            (since 2026-10-09; it reported `true` before) and `kind:
            "break_through"`. Prefer `kind` / `kind_label` for the badge.
          example: true
        kind:
          type: string
          enum:
          - post
          - announcement
          - must_read
          - break_through
          description: |
            The post's kind as Compose names it (Comms::MessageKinds.kind_for):
            `post`, `announcement`, `must_read`, or `break_through` for the
            post a Broadcast contributed to News. Draw the badge from this
            and `kind_label` instead of a priority→kind table.
          example: announcement
        kind_label:
          type: string
          nullable: true
          description: |
            The reader's word for `kind` — the company's own label for posts
            and announcements when it chose one (Communications settings),
            else the kind's name ("Post", "Announcement", "Must-read",
            "Broadcast").
          example: Announcement
        must_read:
          type: boolean
        pinned:
          type: boolean
        pinned_at:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum:
          - draft
          - scheduled
          - published
          - expired
          - archived
        featured:
          type: object
          nullable: true
          description: |
            The post's current Top of News booking for the caller, or null. Since
            Knowledge Layer Phase 3 (E7) featuring is a booking on the one
            placement engine; the shape is unchanged. Editors manage it through
            `/feeds/{id}/feature`.
          properties:
            placement_id:
              type: integer
              description: The booking — what `POST /feeds/{id}/placements/{placement_id}/dismiss`
                and `…/end_now` name.
            starts_at:
              type: string
              format: date-time
            ends_at:
              type: string
              format: date-time
            position:
              type: integer
            can_end_now:
              type: boolean
              description: |
                Detail only (`GET /feeds/{id}`): offer End now — the caller may
                end this booking (the person who placed it, the author, or one the
                booking rule lets place it) and the token holds
                `write:news_feed_editorial_features`. Absent on list rows.
        can_feature:
          type: boolean
          nullable: true
          description: |
            Detail only (`GET /feeds/{id}`): offer Feature on top — the post is
            out and not expired, the token holds
            `write:news_feed_editorial_features`, and the caller may feature it
            for everyone it reaches (the rule `PATCH /feeds/{id}/feature` asks).
            Null on list rows, where it is not worked out per post.
        title:
          type: string
          nullable: true
          description: |
            On a broadcast-sourced post, a recipient whose language differs reads
            the title in their language (never the author); `original_title`
            then carries what was written.
        body:
          type: string
          nullable: true
          description: |
            Markdown. A post stored as HTML (a newsletter's in-app copy) is
            written out as Markdown, with its pictures at addresses signed for
            an hour; the HTML a web view draws is `rendered_body` on the detail.
            Translated like `title`. An unchecked machine translation ends with a
            short "Machine translation." note in the reader's language; a checked
            one carries no note.
        newsletter:
          allOf:
          - "$ref": "#/components/schemas/NewsFeedNewsletterCopy"
          nullable: true
          description: |
            Set only on a newsletter's in-app copy (`source.type` is
            `CommsHub::Issue`); null on every other post. The same words as
            `body`, cut at each hero banner so the banner's title, subtitle and
            buttons can be drawn ON its picture, as the web and the email draw
            them. A client that draws `newsletter.blocks` draws them instead of
            `body`, and makes no link or file card of its own for the links and
            pictures in them — they are part of the newsletter. In the
            reader's language it is `reader_translation.newsletter`, as `body`
            is `reader_translation.body`. Added 2026-10-16; a client that does
            not read it keeps drawing `body`.
        translated:
          type: boolean
          description: Whether `title` / `body` are a translation (then `original_title`
            / `original_body` are set).
        translation_status:
          type: string
          nullable: true
          enum:
          - checked
          - ai_draft
          -
          description: |
            The one reader label's facts (Knowledge Layer P6): `checked` — a
            person checked this translation (`translation_checked_by` names
            them: "Translated from English · Checked by …"); `ai_draft` — AI
            translated it and nobody has checked it yet ("Translated by AI — not
            yet checked"). Null when the caller reads the original.
        translation_checked_by:
          type: string
          nullable: true
          description: Who checked the translation — only when `translation_status`
            is `checked`.
          example: Marie Dubois
        original_title:
          type: string
          nullable: true
          description: The title as written — only when `translated`.
        original_body:
          type: string
          nullable: true
          description: The body in the language it was written in, Markdown like `body`
            — only when `translated`.
        reader_translation:
          type: object
          nullable: true
          description: |
            The post in the caller's own language, when the company translates
            posts for readers ("Translate posts for readers" in Communications
            settings). Null when that is off, when the caller reads English or a
            language the company does not offer, on the caller's own post, on a
            draft or scheduled post, and on a broadcast's post whose `title` /
            `body` already carry the translation (`translated`). `title` and
            `body` above stay as written: show this block's words in their place
            and offer "Show original". A `pending` translation is being written
            now — ask again shortly; reading the post is what starts it.
          properties:
            language:
              type: string
              example: ko
            state:
              type: string
              enum:
              - translated
              - pending
              - awaiting_review
              - failed
              description: |
                `translated` — `title` / `body` hold the translation.
                `pending` — being translated now; show the original meanwhile.
                `awaiting_review` — a Must-Read whose translation a person must
                check first; show the original and say so.
                `failed` — it could not be translated right now; show the original.
            title:
              type: string
              nullable: true
              description: The translated title — only when `translated`.
            body:
              type: string
              nullable: true
              description: The translated body (Markdown, like `body`) — only when
                `translated`.
            newsletter:
              allOf:
              - "$ref": "#/components/schemas/NewsFeedNewsletterCopy"
              nullable: true
              description: |
                A newsletter's in-app copy, translated and cut at its hero
                banners like the post's own `newsletter` — only when `translated`
                and the post is a newsletter copy. A client that draws
                `newsletter.blocks` draws these in their place, as it draws this
                `body` in place of the post's.
            poll_options:
              type: object
              additionalProperties:
                type: string
              description: |
                A poll's choices in the caller's language, keyed by option id —
                only when `translated`. Empty for any other post.
            label:
              type: object
              nullable: true
              description: The one reader label's facts — only when `translated`.
              properties:
                status:
                  type: string
                  enum:
                  - checked
                  - ai_draft
                  - out_of_date
                checked_by:
                  type: string
                  nullable: true
                source_language:
                  type: string
                  example: en
        published_at:
          type: string
          format: date-time
          nullable: true
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When the post leaves the feed — for a must-read, when it stops
            asking for acknowledgement, which can be days after `due_at`.
        due_at:
          type: string
          format: date-time
          nullable: true
          description: |
            A must-read's acknowledge-by moment: the author's "Due by", else
            `expires_at`. Readers who have not acknowledged by then are
            overdue while the post still asks (until `expires_at`). null for a
            post that asks no acknowledgement. The caller's OWN date — moved
            on for someone the post reached late — is
            `acknowledgement_prompt.due_at`.
        editable:
          type: boolean
          description: |
            Whether **the calling user** may currently edit this post's content
            and attachments — author, and either the post is draft/scheduled or
            it was published within the last 15 minutes with the admin
            `content_editing` setting on. Lets clients show or hide the edit and
            add-attachment controls instead of guessing; the server
            re-enforces it on every write regardless. False for an API token
            without write:news_feed.
        can_remove_attachment:
          type: boolean
          description: |
            Whether the caller may remove one of this post's attachments
            (`DELETE /feeds/{feed_id}/media/{media_id}`): the same rule as
            `editable`, with a credential holding destructive:news_feed, which
            that delete-level door asks (a signed-in session always does).
        is_edited:
          type: boolean
          description: |
            True once the author edited this post after it was published —
            a convenience boolean for `edited_at != null` so clients can render
            an "edited" marker without a null check. Applies to any
            `content_type` (update / question / poll).
        edited_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When the post was last edited after publishing. `null` until the
            first post-publish edit; drafts and scheduled posts never set it
            (the marker is published-only).
        audience_type:
          type: string
          enum:
          - everyone
          - segments
        audience_segment_ids:
          type: array
          items:
            type: string
        audience_segments:
          type: array
          description: |
            The post's audience, named, so clients can render it without a
            second roundtrip; `[]` means everyone. For a post sent to segments it
            is parallel to `audience_segment_ids`: numeric ids resolve via
            `NotificationRecipientGroup` (`name` is `null` once the group is
            deleted) and the `direct_reports` pseudo-segment resolves to
            "Author's direct reports". A post sent to a saved audience is one
            entry, id `audience:<id>`. A post sent to specific people or groups
            has one entry per group (its bare id), department (`department:<id>`),
            place (`location:<id>`, or `location_only:<id>` named "<place> only"),
            job title (`job_title:<title>`), role (`role:<role>`), team
            (`reports_of:<user id>`) and attribute filter (`attributes:<n>`) it
            picked, and the people it picked by name as one entry, id `people`,
            named "N people".
          items:
            type: object
            properties:
              id:
                type: string
              name:
                type: string
                nullable: true
        audience_size:
          type: integer
          nullable: true
          minimum: 0
          description: |
            The post's audience: the people its audit-grade audience
            snapshot names — the same figure as `metrics.audience_size` on
            `GET /api/v1/news-feed/feeds/{id}/analytics`, and the set
            `acknowledged_count` is counted within — falling back to the
            reach captured at publish time for a post with no snapshot.
            While the post's works-council group is withheld it counts the
            named people only, as the read and acknowledged counts do. Pair
            with `acknowledged_count` for an "N of M acknowledged" badge. It
            grows when a person who joined the audience after publish
            acknowledges the post or reads it to the end.
            `null` for an unsent post, or one that predates both.
        author_id:
          type: integer
          description: Convenience alias for `author.id` (kept for backwards-compat).
        author:
          "$ref": "#/components/schemas/NewsFeedUserRef"
        ai_assisted:
          type: boolean
          description: |
            Draw the "AI-assisted" label. Resolved for the CALLER (Knowledge
            Layer E6): true only when the post was written with AI help AND
            the caller may be told — the company shows it to readers
            (Communications › Show readers when AI helped write a message), or
            the caller is the author, the delegate who sent it or a moderator.
        sent_by:
          allOf:
          - "$ref": "#/components/schemas/NewsFeedUserRef"
          nullable: true
          description: |
            The delegate who sent this post as its author ("sent by …"), under
            the same caller-resolved rule (Communications › Show readers who
            sent a message on someone's behalf). `null` when there is none or
            the caller may not be told.
        via:
          type: string
          nullable: true
          example: Contests
          description: |
            The app that created this post under the author's name ("via
            Contests"). Always shown; `null` for a person's own post and for a
            broadcast's or newsletter's own feed post.
        requires_acknowledgement:
          type: boolean
          description: |
            Whether THIS POST asks the reader to acknowledge it (must-read).

            Note for broadcast-sourced entries (`source.type == "Broadcast"`):
            this is `false` by design even when the broadcast requires an
            acknowledgement — the post is contributed at priority
            `operational`, so the acknowledgement stays on the broadcast's
            ledger rather than being minted twice. Read
            `source.requires_acknowledgement` / `source.acknowledged` for those
            cards, and acknowledge via
            `POST /api/v1/broadcasts/{source.id}/acknowledge`.
        policy:
          type: object
          nullable: true
          description: |
            The HR policy a Must-Read asks the reader to accept, plus THIS
            caller's acceptance of it. `null` when the post names no policy —
            which is most posts. Set at compose time via `feed[policy_id]` on
            `POST /feeds`; the selectable policies come from
            `GET /news-feed/policies`, and one policy's detail from
            `GET /news-feed/policies/{id}`.

            Acceptance is tracked separately from acknowledging the post: a
            reader can have acknowledged the must-read without having accepted
            the policy, so render the two states independently.
          properties:
            id:
              type: integer
            title:
              type: string
            status:
              type: string
              enum:
              - draft
              - published
              - archived
              description: The policy's own lifecycle state.
            requires_acknowledgment:
              type: boolean
              description: |
                Whether the policy asks to be accepted at all. When false the
                card should name the policy without promising an accept action.
                (Spelled `acknowledgment`, matching the Policy Hub column.)
            accepted:
              type: boolean
              description: |
                Whether this caller has accepted the CURRENT version. A policy
                HR has flagged for re-acknowledgment reads `false` here even
                though an older acceptance row exists — the reader owes a fresh
                acceptance.
            accepted_at:
              type: string
              format: date-time
              nullable: true
              description: When they accepted. `null` unless `accepted` is true.
            url:
              type: string
              nullable: true
              description: |
                Absolute URL of the mobile Policy Hub screen — the one surface
                that both reads and accepts, i.e. the target of the "Read and
                accept" action. `null` when the policy is no longer published,
                or Policy Hub isn't reachable by THIS caller, so a client never
                renders a link that only bounces.
            acknowledge_url:
              type: string
              nullable: true
              description: |
                Absolute URL to POST (empty body) to accept the policy in place
                — `POST /api/v1/policy_hub/policies/{id}/acknowledge` — so a
                card can offer the accept action itself instead of only bouncing
                the reader out to `url`. Idempotent; on success the endpoint
                returns the acknowledgment record.

                `null` whenever that POST would be refused for a reason this
                payload already knows: the policy is no longer published or
                Policy Hub isn't reachable by THIS caller, the policy doesn't
                ask to be accepted (`requires_acknowledgment: false`), it
                requires an e-signature on an e-signature-enabled tenant (where
                signing happens on the web app via `url`), or the policy is not
                this caller's to accept — no acknowledgment row assigning it and
                the caller is outside the policy's live audience, which the
                endpoint refuses with 403. That last case is why `url` can be
                non-null while this is `null`: the Policy Hub screen is readable
                by anyone, and it hides its own accept CTA on the same
                condition, so a card must not offer an accept action there.

                Stays non-null once `accepted` is true — the endpoint is
                idempotent, and a reader HR has flagged for re-acknowledgment
                reads `accepted: false` and needs it again. Also stays non-null
                when an acknowledgment row exists but the policy's audience was
                narrowed afterwards: the row IS the assignment and the endpoint
                honors it, or the reader would be stranded with a pending item
                they can never clear.

                One refusal is deliberately NOT reflected here: incomplete
                required training → 422 `training_required`. That is "blocked
                pending your action", not "not yours" — the web renders a
                disabled "Complete Training First" CTA rather than hiding it —
                so POST and surface the endpoint's message, which names the
                courses.
            acknowledge_url_reason:
              type: string
              nullable: true
              enum:
              - not_published
              - app_unavailable
              - not_applicable
              - esignature_required
              - not_targeted
              description: |
                Why there is no in-place accept action — non-null EXACTLY when
                `acknowledge_url` is `null`, and `null` whenever `acknowledge_url`
                is present. A machine-readable code drawn from the acknowledge
                endpoint's own refusal vocabulary, so a client can branch on the
                same reason whether it reads it here or POSTs and reads the error:
                  * `not_published` — the policy is no longer published (the
                    endpoint answers 404).
                  * `app_unavailable` — the policy is published but Policy Hub is
                    not reachable by THIS caller (403 `app_forbidden`).
                  * `not_applicable` — the policy does not ask to be accepted
                    (`requires_acknowledgment: false`; 422 `not_applicable`).
                  * `esignature_required` — an e-signature policy on an
                    e-signature-enabled tenant; sign from the web app via `url`
                    (422 `esignature_required`).
                  * `not_targeted` — the policy is not this caller's to accept:
                    no acknowledgment row assigns it and the caller is outside
                    the policy's live audience (403 `forbidden`). This is the
                    case where `url` can be non-null while `acknowledge_url` is
                    `null`.
                Incomplete required training is NOT surfaced here — it leaves
                `acknowledge_url` non-null on purpose (see that field).
          required:
          - id
          - title
          - status
          - requires_acknowledgment
          - accepted
          - accepted_at
          - url
          - acknowledge_url
          - acknowledge_url_reason
          example:
            id: 12
            title: Heat Safety Procedure v4
            status: published
            requires_acknowledgment: true
            accepted: false
            accepted_at:
            url: https://acme.workforce.mangoapps.com/m/apps/policy-hub/policies/12
            acknowledge_url: https://acme.workforce.mangoapps.com/api/v1/policy_hub/policies/12/acknowledge
            acknowledge_url_reason:
        requested_action:
          allOf:
          - "$ref": "#/components/schemas/CommsRequestedAction"
          nullable: true
          description: |
            The one thing this post asks THIS reader to do, as the web draws it
            on the post. `null` unless the post was sent to the caller with one.
        topics:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
        can_engage:
          type: boolean
          description: |
            Whether the caller may engage with this post — react, comment,
            mute, unpin, mark seen, complete an action, vote, acknowledge. Hide
            those controls when false. False on a draft or scheduled post (its
            preparers may read it; nobody engages until it goes out), and for
            a reader outside the post's audience — the holder of the "view
            any post" grant, or a Communications admin moderating it — whom
            those endpoints answer 403. For such a reader
            `acknowledgement_prompt` is also null and `poll.allow_comments`
            false. False too for an API token without write:news_feed, which
            every one of those doors asks; for it `poll.allow_comments` is false
            and `acknowledgement_prompt.can_acknowledge` false (the prompt still
            says what the person owes).
        can_pin:
          type: boolean
          description: |
            Whether the caller may pin this post (`POST /feeds/{id}/pin`) —
            `can_engage`, and the post is one the `filter=pinned` list shows:
            published and not expired. An expired or archived post answers 422
            `not_pinnable`; Unpin stays open on `can_engage` so a stale pin can
            be removed.
        discussion_open:
          type: boolean
        discussion_close:
          nullable: true
          description: |
            Moderation close metadata. `null` while the discussion is open
            (`discussion_open: true`); otherwise the active DiscussionClose,
            so clients can render the "Discussion was closed by … on …"
            banner. `closed_by` is `null` for an auto-close (the scheduled
            job has no actor).
          type: object
          properties:
            closed_at:
              type: string
              format: date-time
            closed_by:
              nullable: true
              "$ref": "#/components/schemas/NewsFeedUserRef"
        muted:
          type: boolean
          description: |
            True when the caller has muted notifications for this feed
            (NewsFeed::PostNotificationMute row exists for the current user).
        reactions:
          "$ref": "#/components/schemas/NewsFeedReactionsAggregate"
        reactions_enabled:
          type: boolean
          description: |
            False on a post that takes no reactions — a broadcast's News post
            whose broadcast turned reactions off, or that is archived. POST
            /feeds/{id}/reactions then refuses a new reaction (422
            `reactions_closed`); taking your own off still works.
        comments_allowed:
          type: boolean
          description: |
            Whether `POST /feeds/{id}/comments` takes a comment on this post:
            the post has comments on and nobody closed its discussion
            (`discussion_open`). False on a post written with comments off,
            and on a recognition's mirrored post, whose comments live on the
            recognition (`social_thread`) — POST …/comments answers 403
            `discussion_closed` there. Show a comment box only when this,
            `can_engage` and the tenant's `comments_enabled` and
            `discussion_layer_enabled` (`GET /news-feed/settings`) are all
            true. Same rule as `poll.allow_comments`.
        social_thread:
          type: object
          nullable: true
          description: |
            Set on a post that mirrors another record's reactions and comments
            — today a public recognition (`RecognitionPost`) or an award
            (`Award`) on the News Feed. The post has no thread of its own:
            `reactions` above is that record's set, and its comments are read
            and written on the record's page (`path`). `null` on every post
            that owns its own reactions and comments.
          properties:
            type:
              type: string
              enum:
              - RecognitionPost
              - Award
              description: The record that owns the thread.
            id:
              type: integer
              description: That record's id.
            path:
              type: string
              description: The record's own page, where its comment thread lives (`/recognition/posts/77`).
            comments_count:
              type: integer
              minimum: 0
              description: Comments on that record's thread.
          required:
          - type
          - id
          - path
          - comments_count
          example:
            type: RecognitionPost
            id: 77
            path: "/recognition/posts/77"
            comments_count: 4
        comments_count:
          type: integer
          minimum: 0
          description: Total active comments (top-level + replies) on this feed.
        last_comment:
          nullable: true
          description: |
            Compact preview of the feed's most recent active comment, for the
            list card. `null` when the feed has no comments. The full thread
            (replies, reactions, attachments) loads on demand from
            `GET /feeds/{id}/comments`.
          "$ref": "#/components/schemas/NewsFeedLastComment"
        read_count:
          type: integer
          minimum: 0
          description: |
            Distinct people who have opened this post (any view — the author
            and people outside its audience included). Not the numerator of a
            read rate: a post's "Read by N of M" is `metrics.read_count` of
            `metrics.audience_size` on
            `GET /api/v1/news-feed/feeds/{id}/analytics`. Someone who joins the
            audience after publish and reads it to the end is added to both.
        acknowledged_count:
          type: integer
          minimum: 0
          description: |
            Total acknowledgements (AcknowledgementRecord rows). Meaningful
            on must-read feeds; always 0 on non-must-read.
        has_correct_answer:
          type: boolean
          description: Question posts only
        correct_answer:
          nullable: true
          "$ref": "#/components/schemas/NewsFeedCorrectAnswer"
        question_state:
          "$ref": "#/components/schemas/NewsFeedQuestionState"
        poll:
          nullable: true
          description: Present on Poll feeds (content_type=poll). Option labels (`options[].id/text/position`)
            are returned on both list and detail; per-option vote counts (`options[].votes`)
            are detail-only.
          "$ref": "#/components/schemas/NewsFeedPoll"
        viewer:
          type: object
          description: |
            Caller-specific read/acknowledgement state, returned on every
            list row so mobile cards can render the unread indicator and
            must-read acknowledgement badge without a second roundtrip.
          properties:
            read:
              type: boolean
              description: |
                Whether this feed is in the caller's READ bucket. This is the
                exact negation of the predicate behind the unread badge
                (`GET /api/v1/apps` → communications `unread_count`), this
                endpoint's `meta.unread_counts`, `filter=unread` and the
                unread-first ordering — so a card rendered from `read` can
                never disagree with the badge.

                `false` in two cases: the caller has never opened the feed
                (`read_at` is null), or somebody else has commented since the
                caller last opened the discussion (`unread_comment_count > 0`,
                `read_at` non-null). Use `read_at.present?` — not `read` — for
                "has this person ever opened it", and note a feed can return
                `read: false` with a non-null `read_at`.

                A feed drops back to `read: true` when the caller opens the
                discussion (`GET /api/v1/feeds/{feed_id}/comments`, which
                advances the comment-read watermark) or via
                `POST /api/v1/feeds/mark_all_read`. Marking the card seen
                (`POST /api/v1/feeds/{id}/mark_seen`) records the impression
                but deliberately does NOT clear comment-driven unread.
            read_at:
              type: string
              format: date-time
              nullable: true
              description: |
                First-view read receipt — when the caller first opened this
                feed, or null if they never have. Unaffected by later comment
                activity (see `read`).
            last_seen_at:
              type: string
              format: date-time
              nullable: true
              description: |
                Most recent view of the card (bumped by
                `POST /api/v1/feeds/{id}/mark_seen` on every impression).
                Impression telemetry only — it is NOT the watermark that
                drives `read` / `unread_comment_count`.
            acknowledged:
              type: boolean
            acknowledged_at:
              type: string
              format: date-time
              nullable: true
            unread_comment_count:
              type: integer
              minimum: 0
              description: |
                Number of comments added to this feed since the caller last
                opened its DISCUSSION (created after `comments_read_at`,
                falling back to `read_at`; excluding the caller's own
                comments). `0` when the caller has never opened the feed —
                mirrors the web "N new replies" card, which only appears
                after the first view.

                Deliberately NOT keyed off `last_seen_at`: scrolling a card
                past the viewport must not silently clear replies the caller
                has not read. This is the same watermark `read` and the
                unread badge use, so the three cannot drift.
        acknowledgement_prompt:
          type: object
          nullable: true
          description: |
            What the caller still owes on a must-read — the Acknowledge prompt
            the web and phone strips draw. null when they owe nothing (not a
            must-read that asks, already acknowledged, accepted the policy it
            names, the author, outside its audience, or it stopped asking).
          properties:
            required:
              type: boolean
              description: Always true when the prompt is present.
            can_acknowledge:
              type: boolean
              description: |
                Whether THIS request may make the acknowledgement
                (`POST /feeds/{id}/acknowledge`): false for a token without
                write:news_feed and while viewing as someone else. Draw the
                Acknowledge button only when true; the obligation stands either way.
            title:
              type: string
            guidance:
              type: string
            voice_unavailable_guidance:
              type: string
            acknowledge_label:
              type: string
              example: Acknowledge
            due_at:
              type: string
              format: date-time
              nullable: true
              description: |
                When the caller must acknowledge by — the post's `due_at`,
                moved on for someone it reached late, so the app names the
                date the web strip and Needs you name.
            overdue:
              type: boolean
              description: True once `due_at` has passed while the post still asks.
            due_text:
              type: string
              nullable: true
              description: |
                The strip's own words for that date, in the caller's language and
                time zone — "Due Fri, Oct 30 at 5:00 PM", or "Was due …" once it
                has passed. null when there is no date.
              example: Due Fri, Oct 30 at 5:00 PM
        media:
          type: array
          description: |
            Feed-post attachments (image / gif / video / file / link_preview).
            Returned on both list rows and the detail endpoint so cards can
            render thumbnails / play buttons without a second roundtrip.
            Empty array when the post has no attachments. Same shape as
            comment attachments — see NewsFeedFeedMedia. The post's cover
            image is in this list too, marked `cover: true`, and a picture
            drawn inside `body` is marked `inline: true` (leave it out of the
            gallery). A newsletter's in-app copy lists none of the pictures in
            its body: they are the newsletter's, drawn in its words.
          items:
            "$ref": "#/components/schemas/NewsFeedFeedMedia"
        cover_image:
          type: object
          nullable: true
          description: |
            The post's cover image (the composer's "Featured image"): draw it
            at the top of the post — after the author, before the title — at
            its own shape (`width` / `height`, null when unknown), never
            cropped, and leave it out of the `media` gallery. Null when the
            post has none. `url` is short-lived like every media URL; refetch
            the feed on a 403. A tap on the cover opens `link` when it has
            one, else the picture full size.
          properties:
            media_id:
              type: integer
              description: The `media` item it is.
            url:
              type: string
            alt:
              type: string
              nullable: true
              description: Alt text; null when none was written.
            width:
              type: integer
              nullable: true
            height:
              type: integer
              nullable: true
            link:
              type: string
              nullable: true
              description: |
                Where a tap on the cover goes on a "Tell people" post — the
                course, wiki or Sites page the post announces (its cover is
                that record's own picture), the same in-app path as the post's
                own "Open the course" link in `body`, relative to the tenant's
                host. Null on every other post.
              example: "/apps/training/learner/courses/42?from=catalog"
        inline_media_ids:
          type: array
          description: |
            Detail only. Ids of `media` pictures the rendered body already shows
            inline — leave them out of the gallery.
          items:
            type: integer
      required:
      - id
      - content_type
      - content_category
      - priority
      - status
      - audience_type
      - author_id
      - author
      - reactions
      - comments_count
      - read_count
      - acknowledged_count
      - muted
      - viewer
      - media
    NewsFeedLastComment:
      type: object
      nullable: true
      description: |
        Compact preview of a feed's most recent active comment, embedded on
        list rows (`NewsFeedSummary.last_comment`). Intentionally minimal —
        the full comment object (replies, reactions, attachments,
        is_correct_answer) is returned by `GET /feeds/{id}/comments`.
      properties:
        id:
          type: integer
        author:
          "$ref": "#/components/schemas/NewsFeedUserRef"
        body:
          type: string
          description: Comment text. Empty string for a soft-deleted comment.
        created_at:
          type: string
          format: date-time
      required:
      - id
      - author
      - body
      - created_at
    NewsFeedUserRef:
      type: object
      nullable: true
      description: |
        Author / user reference shape used across the News Feed API. Mirrors the
        `author` block on `NewsFeedComment` so mobile clients have a single
        renderer. `avatar_url` is the resolved CDN URL (helpers.avatar_url) and
        may point to a default avatar when the user hasn't uploaded one.
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
          format: uri
    NewsFeedReactionsAggregate:
      type: object
      description: |
        Aggregate reactions (from `NewsFeed::ReactionsService.aggregate`).
        `my_reaction` is the caller's own emoji_key or null.
      properties:
        total:
          type: integer
          minimum: 0
        top_emojis:
          type: array
          items:
            type: object
            properties:
              emoji_key:
                type: string
              count:
                type: integer
                minimum: 0
        my_reaction:
          type: string
          nullable: true
    FeedWhereChoice:
      type: object
      description: |
        The phone composer's "Where it shows" ticks (`GET /feeds/where_it_shows`
        lists what is offered). Top of Home starts at the first free moment for
        these people and runs 7 days; a store pin runs to the end of its day.
      properties:
        top_of_home:
          type: boolean
        store_pin:
          type: boolean
        store_pin_until:
          type: string
          format: date
          description: The day the pin runs to; default tomorrow.
    FeedWhereItShowsResult:
      type: object
      description: Present when the request sent `where` and a place was in play.
      properties:
        placed:
          type: boolean
          description: False when a place was taken by someone else a moment before
            the write; the post went out without it.
        message:
          type: string
          nullable: true
          description: What was placed or taken off, or why it isn't placed.
    NewsFeedDetail:
      allOf:
      - "$ref": "#/components/schemas/NewsFeedSummary"
      - type: object
        properties:
          rendered_body:
            type: string
            description: Server-rendered Markdown HTML for the body
          poll:
            nullable: true
            "$ref": "#/components/schemas/NewsFeedPoll"
          ai_summary:
            nullable: true
            allOf:
            - "$ref": "#/components/schemas/NewsFeedDiscussionSummary"
            description: |
              PRD 14 §11 — Embedded AI discussion summary. Mirrors the
              `_discussion_summary_card.html.erb` web partial: returned
              when the tenant has `ai_discussion_summarization` enabled
              and a row exists for the feed, else `null`. Both
              `succeeded` and `failed` rows are surfaced so clients can
              render the same "couldn't be generated" fallback the web
              card shows. Detail-only — list rows omit this field.
          poll_summary:
            nullable: true
            allOf:
            - "$ref": "#/components/schemas/NewsFeedPollSummary"
            description: |
              PRD 15 §11 — Embedded AI poll outcome summary. Mirrors the
              `_poll_summary_card.html.erb` web partial: returned only
              when the tenant has `ai_poll_summarization` enabled, the
              poll is closed, and the latest summary `succeeded`. Failed
              rows are admin-visible only and surface as `null` here.
              Detail-only and present only on `content_type=poll` feeds.
          campaign:
            nullable: true
            allOf:
            - type: object
              description: A Communications campaign as a composer draws it (the Campaign
                field).
              required:
              - id
              - name
              - status
              - kickoff_at
              - ended_at
              - open
              - can_change
              properties:
                id:
                  type: integer
                name:
                  type: string
                status:
                  type: string
                  enum:
                  - draft
                  - active
                  - completed
                  - canceled
                  description: Follows the campaign's dates (a Draft turns Active
                    on its kickoff date, an Active one Completed the day after it
                    ends), unless a person set it.
                kickoff_at:
                  type: string
                  format: date-time
                  nullable: true
                ended_at:
                  type: string
                  format: date-time
                  nullable: true
                open:
                  type: boolean
                  description: Draft or Active — it can take new messages.
                can_change:
                  type: boolean
                  description: 'Whether the caller may take a message out of it (false
                    for a token without `write:broadcasts`). False: show its name
                    instead of a choice, as the web does.'
            description: |
              The Communications campaign the post is in (a newsletter's
              in-app copy: its issue's) — for its author or a caller who may
              change that campaign; otherwise `null`, as for a post in no
              campaign. `can_change` is false for a token without
              `write:broadcasts`. Detail-only.
    NewsFeedDiscussionSummary:
      type: object
      description: |
        PRD 14 §11 — JSON view of `NewsFeed::DiscussionSummary`. Returned
        embedded on `NewsFeedDetail.ai_summary` and standalone from
        `GET /feeds/{id}/summary`.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        status:
          type: string
          enum:
          - succeeded
          - failed
        trigger:
          type: string
          enum:
          - threshold
          - manual_close
          - author_request
        comment_count_at_generation:
          type: integer
          minimum: 0
        generated_at:
          type: string
          format: date-time
        model_version:
          type: string
          nullable: true
        failure_reason:
          type: string
          nullable: true
          description: Populated when status == failed.
        summary:
          type: object
          description: |
            Structured payload extracted from `summary_payload`. Always
            present (even when `status == failed`, in which case fields
            below may be blank / default values). `decisions` defaults to
            `"None identified"` when the underlying payload field is
            blank — mirrored from `NewsFeed::DiscussionSummary#decisions`
            so the web view and the API agree without duplicating copy.
          properties:
            takeaways:
              type: array
              items:
                type: string
            decisions:
              type: string
            action_items:
              type: array
              items:
                type: object
                properties:
                  name:
                    type: string
                    nullable: true
                  action:
                    type: string
                  deadline:
                    type: string
                    nullable: true
            sentiment:
              type: string
              nullable: true
              enum:
              - positive
              - neutral
              - negative
              - mixed
            sentiment_reason:
              type: string
              nullable: true
            question_answer_status:
              type: string
              nullable: true
            correct_answer_excerpt:
              type: string
              nullable: true
    NewsFeedPollSummary:
      type: object
      description: |
        PRD 15 §11 — JSON view of `NewsFeed::PollSummary`. Returned
        embedded on `NewsFeedDetail.poll_summary` and standalone from
        `GET /feeds/{id}/poll_summary`.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        status:
          type: string
          enum:
          - succeeded
          - failed
        trigger:
          type: string
          enum:
          - poll_close
          - admin_replay
        generated_at:
          type: string
          format: date-time
        model_version:
          type: string
          nullable: true
        failure_reason:
          type: string
          nullable: true
        summary_text:
          type: string
          nullable: true
          maxLength: 500
          description: Human-readable 2-3 sentence outcome summary (PRD §10 — max
            500 chars). Null when status == failed.
        summary:
          type: object
          properties:
            leading_option_id:
              type: integer
              nullable: true
            leading_option_text:
              type: string
              nullable: true
            winning_percentage:
              type: number
              nullable: true
            participation_rate:
              type: number
              description: Fraction in [0, 1].
            low_participation:
              type: boolean
            is_anonymous:
              type: boolean
            segment_patterns:
              type: array
              items:
                type: object
                additionalProperties: true
    NewsFeedCorrectAnswer:
      type: object
      description: |
        Verified-answer block returned on Question posts that have a marked
        correct answer. Returned both embedded on `NewsFeedSummary.correct_answer`
        and standalone from `GET /feeds/{id}/correct_answer`. `marked_by_user_id`
        identifies the admin / author who **marked** the answer as verified;
        `answer.author` is the user who actually **wrote** the answer comment.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
        comment_id:
          type: integer
        marked_at:
          type: string
          format: date-time
        marked_by_user_id:
          type: integer
        marked_by:
          nullable: true
          type: object
          description: |
            Who marked the answer — the card's "marked by <name>". `name` is
            the person's name, else "Unnamed user"; `null` when that account
            is gone.
          properties:
            id:
              type: integer
            name:
              type: string
        answer:
          nullable: true
          type: object
          description: |
            The marked answer comment + the user who wrote it. Mirrors the
            web "Correct Answer" pinned card. `null` when the comment has
            been soft-deleted or hard-deleted while the outer
            `correct_answer` record persists — mirrors the web partial
            which renders a "[the marked comment is no longer available]"
            placeholder in that case. The outer `comment_id` /
            `marked_at` / `marked_by_user_id` are still returned for audit.
          properties:
            id:
              type: integer
              description: ID of the marked comment.
            body:
              type: string
              description: Comment body as authored (plain text per PRD §13).
            status:
              type: string
              enum:
              - active
              - deleted
            created_at:
              type: string
              format: date-time
            edited_at:
              type: string
              format: date-time
              nullable: true
            author:
              "$ref": "#/components/schemas/NewsFeedUserRef"
              description: |
                User who wrote the marked answer. Distinct from
                `marked_by_user_id`, which is the user who marked it as
                verified (typically the post author or an admin).
    NewsFeedQuestionState:
      type: object
      description: |
        Question posts only. Where the question stands — the marks the web and
        phone post foot draws, from the same code (NewsFeed::QuestionState).
        Read live: marking or unmarking an answer changes it on the next read.
      properties:
        state:
          type: string
          enum:
          - open
          - routed
          - answered
          - answered_by_expert
          - outdated
          description: |
            The first mark: `outdated` (an admin marked the verified answer out
            of date), `answered_by_expert` (the marked answer is the reply of an
            expert the question was routed to), `answered` (any other marked
            answer), `routed` (sent to experts, none of whose replies is the
            answer), else `open`.
        label:
          type: string
          nullable: true
          description: The first mark's words as the web shows them, in the caller's
            language ("Answered by Dana Lee", "Sent to an expert"); `null` when `open`.
        expert:
          nullable: true
          type: object
          description: The expert whose reply is the marked answer; `null` otherwise
            (a routed expert stays unnamed until they answer).
          properties:
            id:
              type: integer
            name:
              type: string
        experts_asked:
          type: integer
          minimum: 0
          description: How many experts the question was first sent to.
        marks:
          type: array
          description: Every mark the post foot draws, in its order — Outdated can
            sit beside Answered by an expert, and Answered beside Sent to experts.
          items:
            type: object
            properties:
              state:
                type: string
                enum:
                - routed
                - answered
                - answered_by_expert
                - outdated
              label:
                type: string
    NewsFeedTopicFollowResponse:
      type: object
      properties:
        topic:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
              enum:
              - active
              - disabled
            managed:
              type: boolean
            following:
              type: boolean
    NewsFeedPoll:
      type: object
      description: |
        Poll state embedded on Poll-type feeds. Returned on both list rows
        and the detail endpoint. Option labels (`options[].id/text/position`)
        are included on both so cards can render the option list from the
        list response; per-option vote counts (`options[].votes`) are
        detail-only to keep list payloads light.
      properties:
        closes_at:
          type: string
          format: date-time
          nullable: true
        voting_mode:
          type: string
          nullable: true
          enum:
          - single
          - multi
          - ranked
          description: |
            Controls how `my_vote_option_ids` should be interpreted:
              * `single` — at most 1 element
              * `multi`  — 0..N elements, order is not meaningful
              * `ranked` — 0..N elements, ordered by rank (most-preferred first)
        is_anonymous:
          type: boolean
          nullable: true
        result_visibility:
          type: string
          nullable: true
          enum:
          - live
          - hidden_until_close
        allow_change_vote:
          type: boolean
          nullable: true
        can_vote:
          type: boolean
          description: |
            Whether POST (a first vote) or PATCH (a change) on
            `/feeds/{id}/poll_votes` would take this caller's ballot now — the
            door's own rule: never through an impersonation token or under
            View-as, on a draft, a closed poll, a vote that may not change, or
            for a reader outside the audience. Draw the ballot only when true.
        allow_comments:
          type: boolean
          description: |
            Whether a comment may be posted on the poll right now
            (`Feed#comments_allowed?`). Folds the author's create-time
            comment setting (the feed-level `comments_enabled` column) together
            with the moderation discussion-close state, so poll cards gate their
            comment input from one field. Set it at create/update time via
            `poll_config_attributes[allow_comments]` (or top-level
            `feed[allow_comments]`).
        is_closed:
          type: boolean
          description: True when poll_config.closes_at has elapsed.
        option_count:
          type: integer
          minimum: 0
          description: Number of poll options.
        my_vote_option_ids:
          type: array
          description: |
            The caller's voted option_ids. Always present; empty array when
            the caller has not voted. Interpret via `voting_mode`:
              * `single` — at most 1 element
              * `multi`  — 0..N elements, order not meaningful
              * `ranked` — 0..N elements, ordered by rank (most-preferred first)
          items:
            type: integer
        results_visible:
          type: boolean
          description: |
            Detail-only. True when the caller is allowed to see the
            per-option vote breakdown; false when results are gated.

            Gating rules (PRD 05 FR-05-06 / FR-05-10), shared with the web
            surface:
              * Closed poll → true (everyone)
              * Author or business admin → true (always)
              * Open + `result_visibility: hidden_until_close` → false
              * Open + `result_visibility: live`, caller has voted → true
              * Open + `result_visibility: live`, caller has not voted → false

            When false, `total_votes` and each `options[].votes` /
            `options[].percent` are returned as `null` so clients can render
            a "results hidden" state without inferring whether anyone has
            voted yet.
        voters_visible:
          type: boolean
          description: |
            True when `GET /feeds/{id}/poll_option_voters` answers this caller
            — draw "See who voted" exactly then. Always false on an anonymous
            poll (`is_anonymous`); on a named poll it follows the
            `results_visible` rules above. The same rule as the web and phone
            "See who voted" and the privacy sentence under every poll.
        total_votes:
          type: integer
          minimum: 0
          nullable: true
          description: |
            Aggregate count of distinct voters (sum of active PollVote rows).
            Detail-only — returned on `GET /feeds/{id}` and on the
            `/feeds/{id}/poll_votes` responses, omitted from list rows.
            `null` when `results_visible` is false.
        options:
          type: array
          description: |
            Poll options. `id`, `text`, and `position` are returned on both
            list rows and the detail endpoint. `votes` (per-option count)
            and `percent` (0–100, rounded) are detail-only — list rows omit
            them. Both are `null` when `results_visible` is false.
          items:
            type: object
            properties:
              id:
                type: integer
              text:
                type: string
              position:
                type: integer
              votes:
                type: integer
                nullable: true
                description: Detail endpoint only. `null` when results are gated.
              percent:
                type: integer
                minimum: 0
                maximum: 100
                nullable: true
                description: |
                  Detail endpoint only. Share of `total_votes` cast for this
                  option, rounded to the nearest integer (0–100). `null` when
                  results are gated. Matches the value rendered by the web
                  `_poll` partial so mobile and web stay in lockstep.
    NewsFeedComment:
      type: object
      properties:
        id:
          type: integer
        held_for_review:
          type: boolean
          description: Waiting for a moderator. Only its author is ever sent such
            a comment.
        feed_id:
          type: integer
        parent_comment_id:
          type: integer
          nullable: true
        depth:
          type: integer
          minimum: 0
          maximum: 1
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            avatar_url:
              type: string
              nullable: true
              format: uri
              description: Full URL of the author's avatar — their uploaded photo
                on the API's own host, else an initials tile.
        body:
          type: string
          description: |
            Comment text. Empty string for soft-deleted comments and for
            attachment-only comments (the server stores a zero-width-space
            sentinel which the serializer surfaces verbatim — clients should
            treat `body.trim()` as the display value).
        status:
          type: string
          enum:
          - active
          - deleted
        edited_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        topics:
          type: array
          description: |
            Topics assigned to this comment — author hashtags (`#tag` in the
            body) plus AI-classified topics. At most 3, ordered by assignment
            time. Same shape as a feed post's `topics`. A comment carrying a
            topic also makes its parent feed match that topic in
            `GET /feeds?topic_id=`.
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
        replies_count:
          type: integer
        reader_translation:
          type: object
          nullable: true
          description: |
            The comment in the caller's language, when the company offers
            reader translation and the comment is in another language (filled
            on `GET /feeds/{id}/comments` for the whole page; `null` on
            create/update, on the caller's own comment, a deleted or
            picture-only comment, and when no translation applies — including
            a comment already written in the caller's language).
            `state`: `translated` (show `body` with "Translated by AI · Show
            original"), `pending` (being translated — ask again shortly),
            `awaiting_review`, or `failed` (show the original).
          properties:
            language:
              type: string
              example: ko
            state:
              type: string
              enum:
              - translated
              - pending
              - awaiting_review
              - failed
            body:
              type: string
              nullable: true
              description: The translated text (mention tokens kept), only when `state`
                is `translated`.
            label:
              type: object
              nullable: true
              properties:
                status:
                  type: string
                  example: ai_draft
                checked_by:
                  type: string
                  nullable: true
                source_language:
                  type: string
                  example: en
        replies:
          type: array
          description: |
            Direct child comments (depth-1 replies) inlined on top-level
            comments. Each reply is a full `NewsFeedComment` payload —
            same shape, including its own `media[]` for attachments.
            Always empty (`[]`) on a reply, since `MAX_DEPTH = 1`.
            Ordered by `created_at` ascending.
          items:
            "$ref": "#/components/schemas/NewsFeedComment"
        reactions:
          "$ref": "#/components/schemas/NewsFeedReactionsAggregate"
        media:
          type: array
          description: |
            Attachments on this comment (PRD 04 Phase 16). Empty when the
            comment has no media. Same shape as feed-post attachments.
          items:
            "$ref": "#/components/schemas/NewsFeedFeedMedia"
        is_correct_answer:
          type: boolean
        ai_assisted:
          type: boolean
          description: |
            Draw the "AI-assisted" label on this comment. Resolved for the
            caller (Knowledge Layer E6): true only when it was written with AI
            help and the company shows that to readers, or the caller wrote it
            or moderates. Always false on a deleted comment.
        expert_answer:
          nullable: true
          description: |
            "Did this solve it?" on a Question post. Set on a top-level
            comment written by an expert the question was sent to: `open`
            for the asker while they are being asked about it, `not_solved`
            for every reader once the asker said it didn't. `null` on every
            other comment — always on a reply and on a deleted comment, on an
            expert's comment the asker is not being asked about (a correct
            answer is marked, the discussion is closed, the question has
            expired or been archived, or they already answered about that
            expert), and for every reader but the asker while the question is
            still open.
          allOf:
          - "$ref": "#/components/schemas/NewsFeedExpertAnswer"
    NewsFeedExpertAnswer:
      type: object
      description: |
        Where the asker's "Did this solve it?" stands on one reply from an
        expert the question was sent to, and what the caller may answer.
      required:
      - state
      - can_mark_correct
      - can_mark_not_solved
      properties:
        state:
          type: string
          enum:
          - open
          - not_solved
          description: |
            `open` — the asker is still being asked whether this reply solved
            it; returned to the asker only, with both `can_*` true.
            `not_solved` — the asker said it didn't: every reader of the
            post is shown "The asker says this didn't solve it" (the asker,
            "You said this didn't solve it"). It stays on that reply until an
            answer from the same expert is marked correct.
        can_mark_correct:
          type: boolean
          description: |
            Offer the caller "Mark as correct" — `POST /feeds/{id}/correct_answer`
            with this comment's id. True only for the asker, while `state` is
            `open` and the post takes engagement (`can_engage`).
        can_mark_not_solved:
          type: boolean
          description: |
            Offer the caller "This didn't solve it" —
            `POST /feeds/{feed_id}/comments/{id}/not_solved`. True only for
            the asker, while `state` is `open` and the post takes engagement
            (`can_engage`).
    NewsFeedFeedMedia:
      type: object
      description: |
        FeedMediaSerializer payload — used for both feed-post and comment
        attachments. `feed_id` is set once the row is claimed onto a post;
        `comment_id` is set once the row is claimed onto a comment. While
        the row is still orphan (uploaded but not yet attached), both are
        null.
      properties:
        id:
          type: integer
        feed_id:
          type: integer
          nullable: true
        comment_id:
          type: integer
          nullable: true
        media_type:
          type: string
          enum:
          - image
          - gif
          - video
          - file
          - link_preview
        processing_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        original_filename:
          type: string
          nullable: true
        mime_type:
          type: string
          nullable: true
        file_size_bytes:
          type: integer
          nullable: true
        storage_url:
          type: string
          nullable: true
          description: Presigned S3 URL or CDN URL — short-lived.
        playback_url:
          type: string
          nullable: true
          description: Signed URL of the 720p H.264 MP4 playback rendition, when the
            video has one. Players should prefer it and fall back to the original
            (`storage_url` / `url`). Null while processing, and for uploads that already
            play everywhere.
        hls_playlist_url:
          type: string
          nullable: true
          deprecated: true
          description: No longer produced; present only on videos transcoded before
            2026-09.
        thumbnail_url:
          type: string
          nullable: true
        alt_text:
          type: string
          nullable: true
        width_px:
          type: integer
          nullable: true
        height_px:
          type: integer
          nullable: true
        position:
          type: integer
        cover:
          type: boolean
          description: The post's cover image — draw it at the top of the post (see
            the feed's `cover_image`), not in the gallery. Always false on a comment
            attachment.
        inline:
          type: boolean
          description: A post's picture drawn inside its text (`body`) — leave it
            out of the gallery under the post, as the web does. Sent on a post's `media`
            only.
        created_at:
          type: string
          format: date-time
    NewsFeedNewsletterCopy:
      type: object
      description: |
        A newsletter's in-app copy as blocks, in order (NewsFeed::NewsletterCopyForClients).
        Joined, the `markdown` blocks and the heroes say what the post's `body` says.
      properties:
        issue_id:
          type: integer
          description: The newsletter issue this post is the copy of.
        blocks:
          type: array
          items:
            type: object
            required:
            - type
            properties:
              type:
                type: string
                enum:
                - markdown
                - hero
                description: "`markdown`: words and pictures between two heroes, written
                  as `body` writes them. `hero`: a banner."
              markdown:
                type: string
                description: Markdown blocks only. Pictures at addresses signed for
                  an hour.
              image_url:
                type: string
                nullable: true
                description: Hero only. The picture the words sit on, cover-fit to
                  the banner (signed for an hour); null when the banner has none —
                  draw `ground`.
              image_alt:
                type: string
                nullable: true
              title:
                type: string
                nullable: true
                description: Hero only. The banner's headline.
              subtitle:
                type: string
                nullable: true
                description: Hero only. The line under the headline.
              align:
                type: string
                enum:
                - left
                - center
                - right
                description: Hero only. Where the words sit.
              look:
                type: string
                enum:
                - dark
                - light
                - brand
                description: 'Hero only. `dark` / `light`: the measured tone of the
                  picture under the words. `brand`: no picture, the company''s brand
                  fill.'
              ink:
                type: string
                description: Hero only. The colour of the title and subtitle (hex).
                example: "#ffffff"
              ground:
                type: string
                description: Hero only. The fill behind the banner — under a picture
                  still loading, or the whole banner when it has none. A hex colour,
                  or `rgba(r, g, b, a)`.
                example: "#9c421a"
              shade:
                type: string
                nullable: true
                description: Hero only. A colour laid over the picture under the words
                  — full strength behind them, fading out across the banner (toward
                  the far side when the words sit left or right) — so they read; null
                  when the picture needs none.
                example: rgba(0, 0, 0, 0.6)
              buttons:
                type: array
                description: Hero only. The banner's buttons, under the subtitle.
                items:
                  type: object
                  properties:
                    label:
                      type: string
                    url:
                      type: string
                    fill:
                      type: string
                      description: The button's colour (hex).
                    ink:
                      type: string
                      description: The label's colour (hex).
    SafetyHubPaginationMeta:
      type: object
      description: |
        Pagination metadata as returned by Api::V1::SafetyHubController#pagination_meta.
        Field shape differs from the global PaginationMeta (this one uses `page`/`total`,
        the global uses `current_page`/`total_count`).
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total:
          type: integer
          example: 137
        total_pages:
          type: integer
          example: 6
      required:
      - page
      - per_page
      - total
      - total_pages
    SafetyHubPersonRef:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    SafetyHubLocationRef:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    Incident:
      type: object
      description: Safety Hub incident (list serializer)
      properties:
        id:
          type: integer
          example: 117
        reference:
          type: string
          example: INC-117
          description: Human-facing reference ("INC-<id>")
        title:
          type: string
          example: Slip on wet floor
        incident_type:
          type: string
          enum:
          - injury
          - near_miss
          - property_damage
          - environmental
          - security
          - vehicle_accident
          - equipment_failure
        severity:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        status:
          type: string
          enum:
          - reported
          - investigating
          - investigation_completed
          - closed
          - cancelled
        occurred_at:
          type: string
          format: date-time
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        reporter:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        anonymous:
          type: boolean
        confidential:
          type: boolean
          description: 'Marked Confidential — the badge the desktop and phone incident
            pages draw. A label only: it does not change who can open the incident.
            A confidential incident is not offered as evidence in Compliance Hub.'
        osha_recordable:
          type: boolean
    IncidentDetail:
      description: Safety Hub incident (detail serializer — adds investigation fields)
      allOf:
      - "$ref": "#/components/schemas/Incident"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          immediate_actions:
            type: string
            nullable: true
          location_specific_notes:
            type: string
            nullable: true
            description: Sub-location / area within the location
          osha_status:
            type: string
            enum:
            - recordable
            - not_recordable
            - under_review
            - not_applicable
            description: Recordability label; "under_review" until a determination
              is stamped, "not_applicable" for a non-injury incident (never an OSHA
              candidate)
          investigator:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          witnesses:
            type: array
            description: Witness roster. PRIVILEGED — empty for a plain reporter;
              only a site manager or the assigned investigator sees names (matches
              the desktop Witnesses card).
            items:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
          investigation_due_at:
            type: string
            format: date-time
            nullable: true
          investigation_started_at:
            type: string
            format: date-time
            nullable: true
          investigation_completed_at:
            type: string
            format: date-time
            nullable: true
          investigation_overdue:
            type: boolean
            description: True when the investigation deadline has passed and the investigation
              is neither completed nor the incident closed/cancelled.
          media:
            type: array
            description: Incident scene photos & videos ("Photos & Videos" section),
              in display order. Visible to any viewer who can reach the record.
            items:
              "$ref": "#/components/schemas/SafetyHubMediaItem"
          investigation:
            nullable: true
            description: Latest investigation narrative. Null unless the caller is
              a PII viewer (site manager or assigned investigator).
            type: object
            properties:
              id:
                type: integer
              investigation_type:
                type: string
                nullable: true
              status:
                type: string
              summary:
                type: string
                nullable: true
              root_cause:
                type: string
                nullable: true
              findings:
                type: string
                nullable: true
              recommendations:
                type: string
                nullable: true
              started_at:
                type: string
                format: date-time
                nullable: true
              completed_at:
                type: string
                format: date-time
                nullable: true
          corrective_actions:
            type: array
            description: CAPA register for this incident (see SafetyHubCorrectiveAction
              schema)
            items:
              "$ref": "#/components/schemas/SafetyHubCorrectiveAction"
          activity_count:
            type: integer
            description: Number of activity-timeline entries (incident updates).
          activity:
            type: array
            description: |
              Activity timeline (incident updates), newest first — the detail
              screen's "Activity" card. NOT privileged: visible to any viewer
              who can reach the record, matching the desktop show page.
            items:
              type: object
              properties:
                id:
                  type: integer
                type:
                  type: string
                  description: Raw update_type (status_change, investigation_note,
                    evidence_added, witness_statement, corrective_action, follow_up,
                    closure).
                type_label:
                  type: string
                  description: Human label for the update type.
                content:
                  type: string
                created_at:
                  type: string
                  format: date-time
                user:
                  nullable: true
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    initials:
                      type: string
          compliance:
            nullable: true
            description: |
              OSHA/WCB determination record backing the "OSHA" detail row and
              the manager "Determine OSHA / WCB Reportability" workflow.
              PRIVILEGED — null unless the caller is a PII viewer (site manager
              or assigned investigator), matching the desktop OSHA + WCB cards.
              The `wcb` sub-block is present only when the tenant has WCB
              compliance enabled.
            type: object
            properties:
              is_injury:
                type: boolean
              osha_recordable:
                type: boolean
                nullable: true
              osha_classification:
                type: string
                nullable: true
                description: e.g. "Fatality", "Days Away From Work", "Job Transfer
                  or Restriction", "Other Recordable Cases". Null until recordable.
              fatality:
                type: boolean
              days_away_from_work:
                type: integer
              days_restricted_work:
                type: integer
              medical_attention_people:
                type: array
                description: Involved people flagged as needing medical attention.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
              wcb:
                type: object
                description: Present only when WCB compliance is enabled for the tenant.
                properties:
                  reportable:
                    type: boolean
                    nullable: true
                  classification:
                    type: string
                    nullable: true
                  province:
                    type: string
                    nullable: true
                  reporting_deadline:
                    type: string
                    format: date
                    nullable: true
                  claim_number:
                    type: string
                    nullable: true
          closed_at:
            type: string
            format: date-time
            nullable: true
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    SafetyHubIncidentPermissions:
      type: object
      description: |
        Viewer-relative capability flags for the incident detail screen, so a
        client renders the right chrome without a second round trip. The
        detail screen's only persona split is the manager-only "Investigation
        Workflow" block.

        **`can_edit` and `can_manage_investigation` answer for the request's
        token as well as the person**, as `SafetyHubPermitPermissions` does:
        each is true only when the endpoints behind it would accept this caller
        and this token — the caller's role and site, the token ceiling (acting
        on someone else's incident through manager privilege needs the wide
        `write:safety_hub`; a `write:own_safety_hub` token reaches only the
        caller's own report) and each endpoint's scope. `can_edit` also asks the
        incident's state; `can_manage_investigation` does not (see its
        description). Gate each control on its flag, never on a role. A session (cookie) caller has no token, so only
        the role, site and state rules apply. `can_view_investigation`
        describes data this response already carries, and `can_close` /
        `close_blocker` describe the web's Close Incident control (there is no
        API close endpoint), so those do not depend on the token.
      properties:
        can_view_investigation:
          type: boolean
          description: Viewer may see the investigation narrative (findings/root cause/recommendations)
            — a site manager or the assigned investigator.
        can_manage_investigation:
          type: boolean
          description: 'Viewer may run the Investigation Workflow''s API actions —
            list the candidate investigators (GET .../investigators), reassign the
            investigator (PATCH .../investigator) and complete the investigation (POST
            .../complete_investigation): a manager at the incident''s site whose token
            holds the wide `read:safety_hub` and `write:safety_hub`. False whenever
            any of those endpoints would refuse this caller or this token. It does
            not read the incident''s status, which still decides which of them applies:
            reassigning needs a `reported` or `investigating` incident (the investigators
            list''s `reassignable`) and completing needs `investigating` — otherwise
            each answers 422.'
        can_edit:
          type: boolean
          description: Viewer may edit the incident (PATCH /safety_hub/incidents/{id})
            — its reporter, or a manager at its site — the incident is in an editable
            state, and the token holds the write scope (the wide `write:safety_hub`
            when a manager edits someone else's report).
        can_close:
          type: boolean
          description: Viewer may close the incident now (a site manager, and nothing
            blocks closure). False for non-managers and whenever close_blocker is
            set. Describes the web control — there is no API close endpoint — so it
            does not depend on the token.
        close_blocker:
          type: string
          nullable: true
          description: |
            User-facing reason the incident cannot be closed yet (the mockup's
            "Close Incident" blocker line), or null when it can. Computed only
            for a site manager; always null for other viewers.
        can_cancel:
          type: boolean
          description: 'Viewer may cancel the report now through POST /safety_hub/incidents/{id}/cancel:
            its reporter while it is still reported, or a site manager, nothing blocks
            the cancel, and the token holds destructive:safety_hub.'
        cancel_hint:
          type: string
          nullable: true
          description: |
            Why a viewer who may cancel this report cannot yet — it has open
            corrective actions, each to be completed or cancelled first — or null.
            The web pages show it in place of the Cancel control. Null whenever
            can_cancel is true, the viewer may never cancel it, or the token lacks
            destructive:safety_hub.
        is_reporter:
          type: boolean
        is_investigator:
          type: boolean
    SafetyHubIncidentCreateRequest:
      type: object
      description: |
        Body for reporting a new incident (POST /safety_hub/incidents). Wrapped
        under an `incident` key. Optional people/witness rows sit alongside it at
        the top level, mirroring the web form. Photos are sent as multipart
        `photos[]` (see the endpoint's multipart schema), never inside this
        object.
      required:
      - incident
      properties:
        incident:
          type: object
          required:
          - title
          - description
          - incident_type
          - severity
          - occurred_at
          properties:
            title:
              type: string
              minLength: 3
              maxLength: 255
              example: Forklift clipped a rack leg
            description:
              type: string
              minLength: 10
              maxLength: 5000
            incident_type:
              type: string
              enum:
              - injury
              - near_miss
              - property_damage
              - environmental
              - security
              - vehicle_accident
              - equipment_failure
              - other
            severity:
              type: string
              enum:
              - low
              - medium
              - high
              - critical
            occurred_at:
              type: string
              format: date-time
              description: When the incident occurred (must not be in the future).
            location_id:
              type: integer
              nullable: true
              description: A site in the caller's business; a foreign id is dropped
                rather than rejected.
            immediate_actions:
              type: string
              nullable: true
            anonymous:
              type: boolean
              default: false
              description: 'When true, the reporter identity is withheld from the
                response and every read surface. Chosen once: it cannot be changed
                after the incident is reported.'
            confidential:
              type: boolean
              default: false
            days_away_from_work:
              type: integer
              nullable: true
              minimum: 0
              maximum: 9999
            days_restricted_work:
              type: integer
              nullable: true
              minimum: 0
              maximum: 9999
            fatality:
              type: boolean
              default: false
            wcb_province:
              type: string
              nullable: true
            alert_id:
              type: integer
              nullable: true
              description: Optional emergency Alert this report opened from; a foreign
                id is dropped.
        people:
          type: array
          description: Optional people-involved rows. A row that fails validation
            is returned as a warning, not an error.
          items:
            type: object
            properties:
              name:
                type: string
              person_type:
                type: string
                enum:
                - employee
                - contractor
                - visitor
                - customer
              role:
                type: string
                nullable: true
              contact_info:
                type: string
                nullable: true
              injury_type:
                type: string
                enum:
                - none
                - first_aid
                - medical_treatment
                - hospitalization
                nullable: true
              body_parts_affected:
                type: string
                nullable: true
              medical_attention:
                type: boolean
                nullable: true
              user_id:
                type: integer
                nullable: true
                description: The person's account in this business, when they work
                  here. An injured person linked to their account is told and can
                  open their own injury record (GET /safety_hub/incidents/{id} returns
                  own_injury_record to them).
        witnesses:
          type: array
          description: Optional witness rows. A row that fails validation is returned
            as a warning, not an error.
          items:
            type: object
            properties:
              name:
                type: string
              contact_info:
                type: string
                nullable: true
              relationship_to_incident:
                type: string
                nullable: true
              statement:
                type: string
                nullable: true
    SafetyObservation:
      type: object
      description: Safety observation (list serializer)
      properties:
        id:
          type: integer
          example: 44
        observation_type:
          type: string
          enum:
          - positive
          - at_risk
          - near_miss
        category:
          type: string
          example: PPE
        status:
          type: string
          enum:
          - submitted
          - under_review
          - resolved
          - closed
        description:
          type: string
          nullable: true
          description: Observation headline/body (observations have no separate title)
        observed_at:
          type: string
          format: date-time
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        observer:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        anonymous:
          type: boolean
        follow_up_required:
          type: boolean
    SafetyObservationDetail:
      description: |
        Safety observation (detail serializer). Adds the follow-up fields, the
        attached photos & videos gallery (`media`), and the drive the
        observation was filed against (`campaign`). Viewable by the observer who
        submitted it, or by a Safety Hub manager whose accessible sites include
        the observation's site (a site-less observation stays manager-visible).
      allOf:
      - "$ref": "#/components/schemas/SafetyObservation"
      - type: object
        properties:
          specific_location:
            type: string
            nullable: true
            description: Sub-location / area within the location
          reported_severity:
            type: string
            nullable: true
            enum:
            - Low
            - Medium
            - High
            - Critical
            description: Severity a public-portal visitor chose; null for any other
              observation.
          action_taken:
            type: string
            nullable: true
          follow_up_notes:
            type: string
            nullable: true
          follow_up_completed_at:
            type: string
            format: date-time
            nullable: true
          follow_up_completed_by:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          campaign:
            "$ref": "#/components/schemas/SafetyObservationCampaignRef"
          media:
            type: array
            description: Attached photos & videos, in display order. Empty when none
              are attached.
            items:
              "$ref": "#/components/schemas/SafetyHubMediaItem"
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    SafetyObservationPermissions:
      type: object
      description: |
        Viewer-relative capability flags returned beside an observation detail
        (GET / PATCH /safety_hub/observations/{id} and POST
        .../complete_follow_up), so a client renders the "Edit observation" and
        "Complete Follow-up" controls without a second round trip.

        **Each `can_*` flag answers for the request's token as well as the
        person**, exactly as `SafetyHubPermitPermissions` does: it is true only
        when this request could take that action now — the caller's role and
        site, the observation's state, the token ceiling (acting on someone
        else's observation through manager privilege needs the wide
        `write:safety_hub`; a `write:own_safety_hub` token reaches only the
        caller's own observations) and the endpoint's `write:safety_hub` scope.
        Gate a control on its flag, never on `is_manager`. A session (cookie)
        caller has no token, so only the role, site and state rules apply.
      properties:
        is_observer:
          type: boolean
          description: The caller submitted this observation. Describes the viewer,
            not an action, so it does not depend on the token.
        is_manager:
          type: boolean
          description: The caller is a Safety Hub manager at the observation's site.
            Describes the viewer, not an action, so it does not depend on the token.
        can_edit:
          type: boolean
          description: The caller may edit the observation (PATCH /safety_hub/observations/{id})
            — its observer, or a manager at its site — with a token that holds the
            write scope, and the wide `write:safety_hub` when a manager edits someone
            else's observation.
        can_complete_follow_up:
          type: boolean
          description: 'The caller may complete the follow-up (POST .../complete_follow_up):
            one is outstanding, and the caller is a manager at the observation''s
            site whose token holds the wide `write:safety_hub`. False once the follow-up
            is complete.'
    SafetyObservationCreate:
      type: object
      description: |
        Fields for submitting a safety observation (POST /safety_hub/observations).
        Only observation_type, category and description are required; observed_at
        defaults to now when omitted. `status` cannot be set — a new observation
        always starts `submitted`. A location_id / safety_observation_campaign_id
        the caller's tenant does not own is dropped rather than rejected.
      required:
      - observation_type
      - category
      - description
      properties:
        observation_type:
          type: string
          enum:
          - positive
          - at_risk
          - near_miss
          description: positive = safe behaviour to reinforce; at_risk / near_miss
            both notify managers + safety officers.
        category:
          type: string
          example: Housekeeping
          description: Free-form category from the tenant's configured list.
        description:
          type: string
          maxLength: 5000
          description: What did you observe? (the headline — observations have no
            separate title).
        observed_at:
          type: string
          format: date-time
          description: When the observation was made. Defaults to now when omitted;
            cannot be in the future.
        specific_location:
          type: string
          maxLength: 255
          description: Sub-location / area within the location ("Bay 3 — mixing station").
        location_id:
          type: integer
          description: A location owned by the caller's business; a foreign id is
            dropped.
        action_taken:
          type: string
          description: Immediate action the observer took (optional).
        follow_up_required:
          type: boolean
          description: Keep the observation open until it is closed out.
        follow_up_notes:
          type: string
          description: Follow-up detail (optional).
        safety_observation_campaign_id:
          type: integer
          description: An active observation drive owned by the caller's business;
            a foreign id is dropped.
    SafetyHubMediaItem:
      type: object
      description: |
        A photo or video attached to a Safety Hub record. Same shape as the
        inspections media payload; `url` is an absolute, signed download URL
        (1-hour expiry) and blanks to null only for a purged/unattached blob.
      properties:
        id:
          type: integer
        media_kind:
          type: string
          enum:
          - photo
          - video
          - voice_memo
          example: photo
        content_type:
          type: string
          example: image/jpeg
        byte_size:
          type: integer
          nullable: true
          example: 2097152
        original_filename:
          type: string
          nullable: true
          example: walkway-hazard.jpg
        url:
          type: string
          nullable: true
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/abc/walkway-hazard.jpg
        thumb_url:
          type: string
          nullable: true
          example: https://acme.workforce.mangoapps.com/rails/active_storage/representations/.../walkway-hazard.jpg
        captured_at:
          type: string
          format: date-time
          nullable: true
      required:
      - id
      - media_kind
    SafetyObservationCampaignRef:
      type: object
      nullable: true
      description: |
        The observation drive this observation was filed against, or null when
        unattributed. Disclosure only — the reward is surfaced so the reporter
        knows the drive offers one; nothing in this API awards it.
      properties:
        id:
          type: integer
          example: 12
        name:
          type: string
          example: Q3 Housekeeping Drive
        reward:
          type: object
          nullable: true
          description: Present only when the drive enables a reward with a description
            or a positive point value.
          properties:
            description:
              type: string
              nullable: true
              example: Coffee voucher
            points:
              type: integer
              nullable: true
              example: 50
    ToolboxTalk:
      type: object
      description: Toolbox talk (list serializer)
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - scheduled
          - in_progress
          - completed
          - cancelled
        scheduled_at:
          type: string
          format: date-time
        duration_minutes:
          type: integer
          nullable: true
        facilitator:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        topic:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            title:
              type: string
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        meeting_location:
          type: string
          nullable: true
    ToolboxTalkDetail:
      description: Toolbox talk (detail serializer — adds completion + attendance)
      allOf:
      - "$ref": "#/components/schemas/ToolboxTalk"
      - type: object
        properties:
          description:
            type: string
            nullable: true
          notes:
            type: string
            nullable: true
          started_at:
            type: string
            format: date-time
            nullable: true
          completed_at:
            type: string
            format: date-time
            nullable: true
          actual_duration_minutes:
            type: integer
            nullable: true
          attendance_count:
            type: integer
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    ToolboxTalkTopic:
      type: object
      description: Toolbox talk topic template
      properties:
        id:
          type: integer
        title:
          type: string
        category:
          type: string
        duration_estimate:
          type: integer
          nullable: true
        content_preview:
          type: string
          nullable: true
        system_template:
          type: boolean
        times_used:
          type: integer
        updated_at:
          type: string
          format: date-time
    SafetyCertificationRequirement:
      type: object
      description: |
        Safety Hub certification REQUIREMENT (template). Distinct from the global
        `Certification` schema, which represents an employee's earned certification.
      properties:
        id:
          type: integer
        name:
          type: string
        category:
          type: string
        description:
          type: string
          nullable: true
        mandatory:
          type: boolean
        active:
          type: boolean
        validity_period_months:
          type: integer
          nullable: true
        renewal_reminder_days:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SafetyHubCorrectiveAction:
      type: object
      description: |
        One row from GET /safety_hub/corrective_actions — a corrective/preventive
        action (Capa::Action), shaped for the mobile "My Corrective Actions" list
        and detail. Priority/status carry the same Bootstrap color tokens the web
        badges use, so a native client renders the identical treatment.
      properties:
        id:
          type: integer
        summary:
          type: string
          description: One-line, markdown-stripped rendering of the description (the
            web list title).
        description:
          type: string
          description: Full action description.
        action_type:
          type: string
          nullable: true
          description: Raw action type (immediate/short_term/long_term/preventive/…),
            null when unset.
        action_type_label:
          type: string
          description: Display label for the action type ("Corrective" when unset).
        priority:
          type: object
          properties:
            value:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
            color:
              type: string
              description: Bootstrap color token (danger/warning/info/secondary)
        status:
          type: object
          properties:
            value:
              type: string
              nullable: true
              description: pending/in_progress/completed/cancelled
            label:
              type: string
              nullable: true
              enum:
              - Not started
              - In progress
              - Completed
              - Cancelled
              description: |
                Capa::Action#status_label — `pending` is "Not started". An action
                reads "In progress" only once someone starts the work
                (POST .../corrective_actions/{id}/start); reassigning it never
                moves the status.
            color:
              type: string
              description: Bootstrap color token
        source:
          type: object
          description: Where the action came from.
          properties:
            label:
              type: string
              description: Inspection / Visit Finding / Incident / Other
            type:
              type: string
              nullable: true
              description: Raw polymorphic source_type, null for a manually-raised
                action
            incident:
              type: object
              nullable: true
              description: |
                Present only for incident-sourced rows. `url` is a mobile deep
                link, but only when the caller may view the incident (a manager,
                or the incident's own reporter); otherwise `null` — the reference
                label is still shown.
              properties:
                id:
                  type: integer
                reference:
                  type: string
                  example: INC-123
                url:
                  type: string
                  nullable: true
                  example: "/m/apps/safety-hub/incidents/123"
        assigned_to:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        assignee_name:
          type: string
          description: Display string — assignee full name, a free-text role, or "Unassigned".
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        due_date:
          type: string
          format: date
          nullable: true
        overdue:
          type: boolean
          description: True for an open action past its due date.
        days_overdue:
          type: integer
          description: Days past due for an overdue action, else 0.
        completed_at:
          type: string
          format: date-time
          nullable: true
        resolution_notes:
          type: string
          nullable: true
        verified:
          type: boolean
          description: ISO 45001 effectiveness-verification state.
        verified_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SafetyHubCorrectiveActionDetail:
      allOf:
      - "$ref": "#/components/schemas/SafetyHubCorrectiveAction"
      - type: object
        description: |
          The GET /safety_hub/corrective_actions/{id} detail payload — the base
          card fields plus the show-screen additions: the ISO 45001
          effectiveness-verification record and the due-date countdown. The
          Completion card ("Completed on … / Closed out by …") reads the base
          `completed_at` and `assignee_name`; the register has no separate
          completer, so no `completed_by` field is emitted.
        properties:
          verified_by:
            allOf:
            - "$ref": "#/components/schemas/SafetyHubPersonRef"
            description: The manager who signed off effectiveness; null until verified.
          effectiveness_notes:
            type: string
            nullable: true
            description: The attestation of what was checked and why the action was
              effective.
          days_until_due:
            type: integer
            nullable: true
            description: Days until the due date (0 if due today or past, null when
              no due date).
    SafetyHubCorrectiveActionPermissions:
      type: object
      description: |
        Viewer-relative capability flags returned alongside a corrective-action
        detail, so a native client renders the right controls without a second
        round trip. Each mirrors the desktop authority exactly.

        **`can_complete` and `can_assign` answer for the request's token as
        well as the person**, as `SafetyHubPermitPermissions` does: each is true
        only when this request could take that action now — the caller's role,
        the action's state, the token ceiling (acting on someone else's action
        through manager privilege needs the wide `write:safety_hub`; a
        `write:own_safety_hub` token reaches only the caller's own actions) and
        the endpoint's `write:safety_hub` scope. Gate each control on its flag,
        never on `is_manager`. A session (cookie) caller has no token, so only
        the role and state rules apply. `can_verify` describes the web
        register's Verify control (there is no API verify endpoint), so it does
        not depend on the token.
      properties:
        is_assignee:
          type: boolean
          description: True when the caller is the action's assignee. Describes the
            viewer, not an action, so it does not depend on the token.
        is_manager:
          type: boolean
          description: True when the caller is a safety-hub manager (manager-or-above
            OR the app admin). Describes the viewer, not an action, so it does not
            depend on the token.
        can_start:
          type: boolean
          description: True when the action is Not started and the caller may start
            work on it (POST .../corrective_actions/{id}/start) — the same people,
            token and scope rule as `can_complete`.
        can_complete:
          type: boolean
          description: 'True when the action is OPEN and the caller may mark it complete
            (POST .../corrective_actions/{id}/complete): its assignee, or a manager
            whose token holds the wide `write:safety_hub` — with a token that holds
            the write scope either way. False while `completion_photo_required`.'
        completion_photo_required:
          type: boolean
          description: 'True when the action comes from an inspection, the company
            requires a photo of the completed work to complete one (Settings › Inspections),
            and none is attached yet. This endpoint takes no photo, so the action
            is completed where the photo is taken (the web register, or POST /inspections/corrective_actions/{id}/complete);
            a completion attempt answers 422 with `error_code: completion_photo_required`.'
        can_assign:
          type: boolean
          description: 'True when the action is OPEN and the caller may reassign it
            (PATCH .../corrective_actions/{id}/assignee): a manager whose token holds
            the wide `write:safety_hub` (`write:own_safety_hub` suffices only for
            a manager reassigning an action assigned to themselves).'
        can_verify:
          type: boolean
          description: |
            True when the tenant's effectiveness-verification policy is on, the
            action is completed and not yet verified, and the caller is a manager
            who is NOT the assignee (self-verification is refused). Describes the
            web register's Verify control — there is no API verify endpoint — so
            it does not depend on the token.
    SafetyHubPermit:
      type: object
      description: |
        One row from GET /safety_hub/permits — a permit to work (WorkPermit),
        shaped for the mobile "My Permits" list card. `display_state` is the
        board state ("active"/"overrun" for an issued permit inside/past its
        window, else the raw status) so the client colours the status pill the
        same way the web board does.
      properties:
        id:
          type: integer
        permit_number:
          type: string
          example: PTW-2026-0042
        title:
          type: string
        permit_type:
          type: string
          description: Raw type key.
          enum:
          - hot_work
          - confined_space
          - working_at_height
          - electrical
          - excavation
          - lifting
          - general
        type_label:
          type: string
          description: Display label for the permit type (e.g. "Hot work").
        status:
          type: string
          description: Raw lifecycle status.
          enum:
          - draft
          - requested
          - approved
          - suspended
          - closed
          - cancelled
          - expired
        display_state:
          type: string
          description: |
            Board state — the raw status, except an issued permit reads
            `active` inside its work window and `overrun` past it.
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
        area:
          type: string
          nullable: true
          description: Free-text area within the site (e.g. "Bay 4").
        location:
          "$ref": "#/components/schemas/SafetyHubLocationRef"
        vendor:
          type: object
          nullable: true
          description: The contractor/vendor performing the work, when set.
          properties:
            id:
              type: integer
            name:
              type: string
    SafetyHubPermitDetail:
      allOf:
      - "$ref": "#/components/schemas/SafetyHubPermit"
      - type: object
        description: |
          The GET /safety_hub/permits/{id} detail payload — the base card
          fields plus the show-page detail: requester (issuer) / authoriser
          (approver), the type's hazards, the precaution checklist with each
          control's confirmed state, and the closure record.
        properties:
          description:
            type: string
            nullable: true
          requested_by:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          approved_by:
            allOf:
            - "$ref": "#/components/schemas/SafetyHubPersonRef"
            description: The authoriser who issued the permit; null until approved.
          approved_at:
            type: string
            format: date-time
            nullable: true
          approval_notes:
            type: string
            nullable: true
          crew:
            type: array
            description: |
              The people named as working under the permit (members of this
              business), by name. Being on the crew lets a person open the
              permit and hear when it is issued, suspended, cancelled, past
              its end time or expired; it grants no edit or close right. Set with
              `work_permit[crew_user_ids][]` on create and update.
            items:
              "$ref": "#/components/schemas/SafetyHubPersonRef"
          hazards:
            type: array
            description: |
              The permit type's full hazard checklist, each with the hazard
              text and whether it is checked for this permit. Any hazard
              carried on the permit but absent from the type catalog is
              appended and always returned checked.
            items:
              type: object
              properties:
                text:
                  type: string
                checked:
                  type: boolean
          precautions:
            type: array
            description: |
              The permit type's full precaution checklist, each with the
              control text and whether it has been confirmed.
            items:
              type: object
              properties:
                text:
                  type: string
                confirmed:
                  type: boolean
          precautions_complete:
            type: boolean
            description: True when every required precaution has been confirmed.
          isolations:
            type: string
            nullable: true
          ppe:
            type: string
            nullable: true
          gas_test:
            type: string
            nullable: true
          emergency_arrangements:
            type: string
            nullable: true
          suspended_reason:
            type: string
            nullable: true
          cancelled_reason:
            type: string
            nullable: true
          area_left_safe:
            type: boolean
            description: Confirmed at close-out that the area was left safe.
          closure_notes:
            type: string
            nullable: true
          closed_by:
            "$ref": "#/components/schemas/SafetyHubPersonRef"
          closed_at:
            type: string
            format: date-time
            nullable: true
          contractor_prequalification:
            nullable: true
            description: |
              The permit's "Contractor" card. Null for an own-crew permit (no
              vendor). When a vendor is present the block is always returned so
              a client can render the "no current pre-qualification" (and, when
              `required`, "cannot be issued") state without a second call.
            type: object
            properties:
              required:
                type: boolean
                description: Whether the tenant requires a current pre-qualification
                  to issue a contractor permit.
              current:
                type: boolean
                description: Whether a live (approved/conditional, unexpired) pre-qualification
                  exists for the vendor.
              status:
                type: string
                nullable: true
                description: Pre-qualification status (draft/approved/conditional/…);
                  null when there is none.
              risk_tier:
                type: string
                nullable: true
              valid_until:
                type: string
                format: date
                nullable: true
              conditions:
                type: string
                nullable: true
              lapsed_on:
                type: string
                format: date
                nullable: true
                description: 'When `current` is false: the date the contractor''s
                  latest pre-qualification lapsed (null when it never had one, or
                  it was rejected or never decided).'
          certificate_gaps:
            type: array
            description: |
              The contractor's certificates ticked "Required for site work" in
              Contracts that do NOT cover the work still to come on this permit:
              expired, expiring before the work ends, taking effect only after
              it starts, or denied in review — each certificate judged on its
              own (a renewal with the same type and title replaces an older
              one) — plus "No certificate of insurance on file" when no
              reviewed certificate of insurance covers the visit. Empty for an
              own-crew permit and once the visit is over (closed / cancelled /
              expired, or past its end time). Issuing or resuming a permit with
              gaps needs `proceed_anyway` + `certificate_override_reason` +
              `confirmed_gap_keys`; an issued permit can instead record
              POST /safety_hub/permits/{id}/confirm_certificate_cover.
            items:
              "$ref": "#/components/schemas/SafetyHubPermitCertificateGap"
          certificate_override:
            nullable: true
            type: object
            description: |
              Present when someone proceeded although the contractor had
              certificate gaps — the LATEST such confirmation (at issue,
              resume or cover confirmation): the step, the reason, the gaps
              confirmed, and the person who made that decision.
            properties:
              action:
                type: string
                enum:
                - issued
                - resumed
                - cover_confirmed
              reason:
                type: string
              gaps:
                type: array
                items:
                  type: object
                  properties:
                    key:
                      type: string
                    certification_id:
                      type: integer
                      nullable: true
                    cert_type:
                      type: string
                      nullable: true
                    label:
                      type: string
                    state:
                      type: string
                      enum:
                      - expired
                      - expires_during_work
                      - not_yet_effective
                      - rejected
                      - missing
                    expires_at:
                      type: string
                      format: date-time
                      nullable: true
                    expires_on:
                      type: string
                      format: date
                      description: Present only when the cover is recorded as a date
                        (the pre-qualification insurance).
                    summary:
                      type: string
              confirmed_by:
                "$ref": "#/components/schemas/SafetyHubPersonRef"
              confirmed_at:
                type: string
                format: date-time
          created_at:
            type: string
            format: date-time
          updated_at:
            type: string
            format: date-time
    SafetyHubPermitPermissions:
      type: object
      description: |
        Viewer-relative capability flags returned beside every permit detail
        (GET /safety_hub/permits/{id}, and the response of each permit write), so
        a client renders the detail's controls — Save, Request approval, the
        precaution checklist, Issue, Cancel, Close out, Suspend, Resume, Cover
        confirmed — without a second round trip.

        **Each `can_*` flag answers for the request's token as well as the
        person** (owner decision 2026-10-06): it is true only when this request
        could take that action now — the caller's role and site, the permit's
        state, the token ceiling (acting on someone else's permit through
        manager privilege needs the wide `write:safety_hub`; a
        `write:own_safety_hub` token reaches only the caller's own permits) and
        the scope the action's endpoint asks (`write:safety_hub`, or
        `destructive:safety_hub` for cancel, close and suspend). Gate each control on its
        flag, never on `is_manager`: a false flag is a request the server would
        refuse. A session (cookie) caller has no token, so only the role, site
        and state rules apply.
      properties:
        is_requester:
          type: boolean
          description: The caller raised this permit. Describes the viewer, not an
            action, so it does not depend on the token.
        is_manager:
          type: boolean
          description: The caller is a Safety Hub manager at the permit's site. Describes
            the viewer, not an action, so it does not depend on the token.
        can_edit:
          type: boolean
          description: The caller may save the permit (PATCH /safety_hub/permits/{id})
            — its requester while it is a draft or requested, or a manager at its
            site while it is open — with a token that holds the write scope, and the
            wide `write:safety_hub` when a manager saves someone else's permit.
        can_request_approval:
          type: boolean
          description: 'The caller may send the permit for approval (POST .../request_approval):
            the same people and token as `can_edit`, on a `draft` permit.'
        can_confirm_precautions:
          type: boolean
          description: 'The authoriser-only precaution checklist is editable: `can_edit`,
            for a manager at the permit''s site, while the permit is open.'
        can_approve:
          type: boolean
          description: 'The caller may issue the permit (POST .../approve): a manager
            at its site who did not request it, whose token holds the wide `write:safety_hub`,
            on a `requested` permit whose work window has not ended and — when the
            tenant requires it — whose contractor has a current pre-qualification.
            Stays true while precautions are outstanding: send them in the same POST
            (`work_permit[precautions]`, editable when `can_confirm_precautions`),
            or the server answers 422. Stays true while the contractor has `certificate_gaps`
            — issuing then needs `proceed_anyway`, `certificate_override_reason` and
            `confirmed_gap_keys` (a 422 with `confirmation_required: true` until they
            are sent): a prompt, not a refusal.'
        can_cancel:
          type: boolean
          description: 'The caller may cancel the permit (POST .../cancel): the same
            people as `can_edit` — its requester while it is a draft or requested,
            or a manager at its site while it is open — on a permit that is not closed,
            cancelled or expired. Needs `destructive:safety_hub`, and the wide `write:safety_hub`
            when a manager cancels someone else''s permit.'
        can_close:
          type: boolean
          description: The caller may close the permit out (POST .../close) — its
            requester, its authoriser or a manager at its site — and it is issued,
            suspended or expired (an expired permit still owes its close-out). Needs
            `destructive:safety_hub`, and the wide `write:safety_hub` when a manager
            closes a permit they are not on.
        can_suspend:
          type: boolean
          description: 'The caller may suspend the permit (POST .../suspend): a manager
            at its site, on an issued (`approved`) permit, whose token holds the wide
            `write:safety_hub` and `destructive:safety_hub`.'
        can_resume:
          type: boolean
          description: 'The caller may resume the permit (POST .../resume): a manager
            at its site, on a `suspended` permit, whose token holds the wide `write:safety_hub`.
            Stays true while the contractor has `certificate_gaps` — the resume then
            needs `proceed_anyway`, `certificate_override_reason` and `confirmed_gap_keys`
            (a 422 with `confirmation_required: true` until they are sent): a prompt,
            not a refusal.'
        can_confirm_certificate_cover:
          type: boolean
          description: 'The caller may record "Cover confirmed" (POST .../confirm_certificate_cover):
            a manager at the permit''s site whose token holds the wide `write:safety_hub`,
            on an issued permit whose contractor has certificate gaps nobody has confirmed
            yet.'
    SafetyHubPermitCertificateGap:
      type: object
      description: |
        One contractor certificate problem for a permit's work window
        (Vendors::Certification.on_site_gaps). Only certificates ticked
        "Required for site work" count; each is judged on its own, and a
        renewal with the same type and title replaces an older one.
      properties:
        key:
          type: string
          example: cert:42
          description: Stable identity of the gap — `cert:<certification_id>`, or
            `missing:insurance`. Send the keys you showed the user as `confirmed_gap_keys`.
        certification_id:
          type: integer
          nullable: true
          description: Null for `missing`.
        cert_type:
          type: string
          nullable: true
          enum:
          - soc2
          - coi
          - w9
          - security_audit
          - insurance
          - other
          description: Null for `missing`.
        label:
          type: string
          example: Certificate of insurance
        state:
          type: string
          enum:
          - expired
          - expires_during_work
          - not_yet_effective
          - rejected
          - missing
        expires_at:
          type: string
          format: date-time
          nullable: true
        expires_on:
          type: string
          format: date
          nullable: true
          example: '2026-09-22'
          description: The calendar date the cover ends — the contractor pre-qualification's
            insurance date (`missing:prequalification_insurance`), or a certificate's
            recorded expiry day; null only when there is no expiry (a missing certificate).
            Print it as is — `expires_at` is an instant and can read as another day
            in a zone away from the business's.
        effective_at:
          type: string
          format: date-time
          nullable: true
          description: The certificate's issue (effective) date.
        replacement_pending_review:
          type: boolean
          description: A vendor-portal upload that would cover the window is awaiting
            review in Contracts (it is never counted as cover).
        summary:
          type: string
          example: Certificate of insurance expired Sep 15, 2026
    SafetyHubPermitCertificateConfirmation:
      type: object
      description: |
        The "proceed anyway" a permit move needs while the contractor has
        certificate gaps (CTR-030). Ignored when there are none.
      properties:
        proceed_anyway:
          type: boolean
          description: Required (true) when the contractor has certificate gaps.
        certificate_override_reason:
          type: string
          maxLength: 2000
          description: Required, non-blank, with `proceed_anyway`. Recorded on the
            permit.
        confirmed_gap_keys:
          type: array
          items:
            type: string
          description: 'The `key` of every gap you showed the user. A live gap missing
            from this list is refused (`certificate_gaps_changed: true`).'
    SafetyHubPermitCertificateGapRefusal:
      type: object
      description: |
        422 body of a permit move refused over contractor certificate gaps (or
        any other 422 on that move, which carries only `error` and `status`).
        `error.code` is `validation_failed` and `error.message` a
        channel-neutral sentence for display; the other fields say what to do.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: validation_failed
            message:
              type: string
        status:
          type: string
        confirmation_required:
          type: boolean
          description: True — resend with `proceed_anyway`, a reason and the `confirmed_gap_keys`
            below.
        certificate_gaps_changed:
          type: boolean
          description: True when a live gap was not among `confirmed_gap_keys` — show
            the new list before asking again.
        certificate_gaps:
          type: array
          items:
            "$ref": "#/components/schemas/SafetyHubPermitCertificateGap"
    SafetyHubKbCategoryFacet:
      type: object
      description: |
        One category chip from GET /safety_hub/knowledge_base — a filter value,
        its display label, and the un-paginated number of articles behind it.
        The `all` facet carries the total across every category.
      properties:
        value:
          type: string
          example: ppe_guides
          description: The category filter value (or `all` for the total chip).
        label:
          type: string
          example: PPE Guides
        count:
          type: integer
          description: Un-paginated number of articles in this category (respecting
            the source_type filter).
    SafetyHubKnowledgeBaseEntry:
      type: object
      description: |
        One row from GET /safety_hub/knowledge_base — a safety knowledge base
        article (KnowledgeBaseEntry, domain :safety) shaped for the mobile
        "Knowledge Base" list card: source type + display label, title, the FAQ
        question as the card subtitle, category + display label, status, author,
        and timestamps.
      properties:
        id:
          type: integer
        title:
          type: string
        source_type:
          type: string
          description: Raw content-type key.
          enum:
          - faq
          - document
          - url
          - video
        source_type_label:
          type: string
          description: Display label for the source type (e.g. "Safety FAQ", "Document").
        category:
          type: string
          description: Raw category key.
        category_label:
          type: string
          description: Display label for the category (e.g. "PPE Guides").
        question:
          type: string
          nullable: true
          description: The FAQ question, shown as the card subtitle; null for non-FAQ
            types.
        status:
          type: string
          description: |
            Lifecycle status. A member only ever sees `active`; a manager also
            sees `draft`, `processing`, `failed`, `archived`, `pending_review`.
          enum:
          - draft
          - processing
          - active
          - failed
          - archived
          - pending_review
        created_by:
          "$ref": "#/components/schemas/SafetyHubPersonRef"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SafetyHubKnowledgeBaseEntryDetail:
      allOf:
      - "$ref": "#/components/schemas/SafetyHubKnowledgeBaseEntry"
      - type: object
        description: |
          The GET /safety_hub/knowledge_base/{id} detail payload — the base
          card fields plus the show-page detail: the answer / extracted body,
          any additional Q&A pairs, a safe external URL, and attached-file
          metadata.
        properties:
          answer:
            type: string
            nullable: true
            description: The FAQ answer; null for non-FAQ types.
          content:
            type: string
            nullable: true
            description: Extracted / crawled / transcribed body for document, url
              and video entries.
          description:
            type: string
            nullable: true
          faq_items:
            type: array
            description: Additional Q&A pairs beyond the primary question/answer (empty
              for legacy column-authored FAQs).
            items:
              type: object
              properties:
                question:
                  type: string
                answer:
                  type: string
          source_url:
            type: string
            nullable: true
            description: A safe absolute http(s) URL for url-sourced entries; null
              otherwise (re-validated at the sink).
          file:
            type: object
            nullable: true
            description: Attached-file metadata for a document/video entry; null when
              nothing is attached.
            properties:
              filename:
                type: string
              content_type:
                type: string
              byte_size:
                type: integer
              url:
                type: string
                description: Absolute download URL with a 1-hour signed expiry.
          minimum_role:
            type: integer
            description: Role floor for access (0=super_admin … 4=member).
    SafetyHubSubmission:
      type: object
      description: One row from GET /safety_hub/submissions — UI-ready metadata.
      properties:
        id:
          type: integer
          description: ID of the underlying incident or observation
        type:
          type: object
          description: Kind of submission plus UI metadata (icon name + color token)
          properties:
            kind:
              type: string
              enum:
              - incident
              - observation
            label:
              type: string
              example: Incident
            icon:
              type: string
              example: alert-triangle
              description: Stable icon-name token (client maps to its icon set)
            icon_color:
              type: string
              example: danger
              description: 'Bootstrap color token: success / warning / danger / info
                / primary / dark / secondary'
        summary:
          type: string
          description: Headline — incident title or truncated observation description
        category:
          type: string
          nullable: true
          description: incident_type for incidents, observation category for observations
        severity:
          type: object
          description: Severity (incidents) or observation_type (observations) with
            display label and color
          properties:
            value:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
            color:
              type: string
              description: Bootstrap color token
        location:
          type: object
          nullable: true
          properties:
            name:
              type: string
        date:
          type: string
          format: date-time
          description: |
            Event date — `occurred_at` (incidents) or `observed_at`
            (observations). Note: this is NOT the sort key. The feed is
            ordered by `submitted_at` desc.
        submitted_at:
          type: string
          format: date-time
          description: |
            When the row was filed (record `created_at`). This is the sort
            key for the feed (descending — most recent submissions first).
        status:
          type: object
          description: |
            Status with display label + color. For observations needing follow-up,
            the value is the synthetic `follow_up_required` (color `warning`)
            instead of the raw enum value — matching the desktop badge override.
          properties:
            value:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
            color:
              type: string
        anonymous:
          type: boolean
          description: |
            Whether the row was filed anonymously. Always `false` in the
            personal feed (anonymous rows are filtered upstream). May be
            `true` in the team feed (`team=true`), which includes anonymous
            rows so managers can triage them.
        confidential:
          type: boolean
          description: |
            Whether the incident is marked Confidential (the badge its detail
            page draws). Always `false` for an observation.
        submitter:
          type: object
          nullable: true
          description: |
            The user who filed the row — `reporter` for incidents,
            `observer` for observations. `null` when `anonymous: true`
            so the team feed doesn't leak the identity of anonymous
            reporters. `null` is also possible if the underlying user
            record has been removed.
          properties:
            id:
              type: integer
            name:
              type: string
              description: User's full name
        investigator:
          type: object
          nullable: true
          description: |
            Assigned investigator for incident rows
            (`Incident.assigned_investigator`). Always `null` for
            observation rows — observations have no investigator
            concept. Also `null` for incidents that have not yet had
            an investigator assigned.
          properties:
            id:
              type: integer
            name:
              type: string
              description: User's full name
        due_date:
          type: string
          format: date-time
          nullable: true
          description: |
            Investigation due date for incident rows
            (`Incident.investigation_due_at`). Always `null` for
            observation rows — observations have no due-date concept.
            Also `null` for incidents whose investigation has not
            been scheduled with a deadline.
        url:
          type: string
          description: Deep-link to the desktop record (e.g., `/apps/safety-hub/incidents/123`)
    SafetyHubSubmissionsSummary:
      type: object
      description: |
        Headline tile counts shown above the submissions feed. Scope follows
        the `team` query parameter so the tiles always match the rows in the
        feed:
          * `team` absent or `false` — the **current user's** own submissions
            this calendar month, with anonymous rows excluded.
          * `team=true` — **every submission in the business** this calendar
            month, including anonymous rows (manager-gated, same gate as the
            feed itself).
        Disabled per-module toggles (`incidents_enabled`,
        `observations_enabled`) contribute `0` in both scopes.
      properties:
        this_month:
          type: object
          description: Total submissions (incidents + observations) this calendar
            month, scoped per the `team` parameter
          properties:
            label:
              type: string
              example: This Month
            count:
              type: integer
              example: 6
            color:
              type: string
              example: info
              description: 'Bootstrap color token: success / warning / danger / info
                / primary / dark / secondary'
            icon:
              type: string
              example: calendar
              description: Stable icon-name token (client maps to its icon set)
        incidents:
          type: object
          description: Incident submissions this calendar month, scoped per the `team`
            parameter
          properties:
            label:
              type: string
              example: Incidents
            count:
              type: integer
              example: 3
            color:
              type: string
              example: danger
            icon:
              type: string
              example: alert-triangle
        observations:
          type: object
          description: Observation submissions this calendar month, scoped per the
            `team` parameter
          properties:
            label:
              type: string
              example: Observations
            count:
              type: integer
              example: 3
            color:
              type: string
              example: primary
            icon:
              type: string
              example: eye
    Holiday:
      type: object
      description: Business holiday information
      properties:
        id:
          type: integer
          description: Unique holiday ID
          example: 1
        name:
          type: string
          description: Holiday name
          example: New Year's Day
        date:
          type: string
          format: date
          description: Holiday date
          example: '2024-01-01'
        description:
          type: string
          nullable: true
          description: Holiday description
          example: New Year's Day celebration
        recurring:
          type: boolean
          description: Whether this holiday recurs annually
          example: true
        business_id:
          type: integer
          description: Business ID
          example: 1
        created_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
      required:
      - id
      - name
      - date
      - business_id
      - created_at
      - updated_at
    HolidayDetailed:
      allOf:
      - "$ref": "#/components/schemas/Holiday"
      - type: object
        properties:
          day_of_week:
            type: string
            description: Day of the week
            example: Monday
          week_of_year:
            type: integer
            description: Week number of the year
            example: 1
          month_name:
            type: string
            description: Month name
            example: January
          is_weekend:
            type: boolean
            description: Whether the holiday falls on a weekend
            example: false
          days_until:
            type: integer
            nullable: true
            description: Days until this holiday (null if past)
            example: 30
          metadata:
            type: object
            description: Additional holiday metadata
            properties:
              year:
                type: integer
                example: 2024
              month:
                type: integer
                example: 1
              day:
                type: integer
                example: 1
              quarter:
                type: integer
                example: 1
              is_past:
                type: boolean
                example: false
              is_today:
                type: boolean
                example: false
              is_future:
                type: boolean
                example: true
    BlackoutPeriod:
      type: object
      description: Leave blackout period information
      properties:
        id:
          type: integer
          description: Unique blackout period ID
          example: 1
        name:
          type: string
          description: Blackout period name
          example: Winter Holiday Season
        description:
          type: string
          nullable: true
          description: Blackout period description
          example: No leave allowed during winter holidays
        start_date:
          type: string
          format: date
          description: Start date of blackout period
          example: '2024-12-15'
        end_date:
          type: string
          format: date
          description: End date of blackout period
          example: '2024-12-31'
        block_requests:
          type: boolean
          description: Whether requests are completely blocked or require special
            approval
          example: true
        status:
          type: string
          description: Current status of the blackout period
          example: upcoming
        duration_days:
          type: integer
          description: Duration in days
          example: 17
        created_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-01T00:00:00Z'
      required:
      - id
      - name
      - start_date
      - end_date
      - block_requests
      - status
      - duration_days
    BlackoutPeriodDetailed:
      allOf:
      - "$ref": "#/components/schemas/BlackoutPeriod"
      - type: object
        properties:
          leave_types:
            type: array
            description: Leave types affected by this blackout
            items:
              "$ref": "#/components/schemas/LeaveTypeBasic"
          locations:
            type: array
            description: Locations affected by this blackout
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                name:
                  type: string
                  example: Main Office
                address:
                  type: string
                  example: 123 Main St
          applies_to_all_leave_types:
            type: boolean
            description: Whether this applies to all leave types
            example: true
          applies_to_all_locations:
            type: boolean
            description: Whether this applies to all locations
            example: true
          days_until_start:
            type: integer
            nullable: true
            description: Days until blackout starts (null if past)
            example: 30
          days_until_end:
            type: integer
            nullable: true
            description: Days until blackout ends (null if past)
            example: 47
          is_current:
            type: boolean
            description: Whether this blackout is currently active
            example: false
          is_upcoming:
            type: boolean
            description: Whether this blackout is upcoming
            example: true
          is_past:
            type: boolean
            description: Whether this blackout is in the past
            example: false
          created_by:
            type: object
            nullable: true
            description: User who created this blackout period
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: Admin User
          impact_message:
            type: string
            description: Message describing the impact of this blackout
            example: Leave requests are completely blocked during this period
    LeaveCoverageAvailability:
      type: object
      description: Leave availability check result
      properties:
        date:
          type: string
          format: date
          description: Date checked
          example: '2024-03-15'
        available:
          type: boolean
          description: Whether leave is available
          example: true
        unlimited:
          type: boolean
          description: Whether coverage is unlimited
          example: false
        current_off:
          type: integer
          description: Number of people currently off
          example: 2
        max_allowed:
          type: integer
          nullable: true
          description: Maximum people allowed off (null if unlimited)
          example: 5
        available_spots:
          type: integer
          nullable: true
          description: Available spots remaining (null if unlimited)
          example: 3
        limit_type:
          type: string
          description: Type of coverage limit
          example: percentage
        message:
          type: string
          description: Human-readable availability message
          example: 3 spots available out of 5 maximum
        metadata:
          type: object
          description: Additional date metadata
          properties:
            day_of_week:
              type: string
              example: Friday
            is_weekend:
              type: boolean
              example: false
            is_holiday:
              type: boolean
              example: false
            is_blackout_period:
              type: boolean
              example: false
      required:
      - date
      - available
      - unlimited
      - current_off
      - message
      - metadata
    LeaveCoverageDateRange:
      type: object
      description: Leave coverage for date range
      properties:
        start_date:
          type: string
          format: date
          example: '2024-03-15'
        end_date:
          type: string
          format: date
          example: '2024-03-17'
        total_days:
          type: integer
          description: Total days in range
          example: 3
        coverage_data:
          type: array
          description: Coverage data for each day
          items:
            allOf:
            - "$ref": "#/components/schemas/LeaveCoverageAvailability"
            - type: object
              properties:
                week_of_year:
                  type: integer
                  example: 11
                month:
                  type: integer
                  example: 3
        summary:
          type: object
          description: Summary statistics for the range
          properties:
            total_days:
              type: integer
              example: 3
            available_days:
              type: integer
              example: 2
            blocked_days:
              type: integer
              example: 1
            weekend_days:
              type: integer
              example: 0
            holiday_days:
              type: integer
              example: 0
            blackout_days:
              type: integer
              example: 1
            availability_percentage:
              type: number
              example: 66.7
      required:
      - start_date
      - end_date
      - total_days
      - coverage_data
      - summary
    LeaveCoverageAlternatives:
      type: object
      description: Alternative date suggestions
      properties:
        original_request:
          type: object
          description: Original requested dates
          properties:
            start_date:
              type: string
              format: date
              example: '2024-03-15'
            end_date:
              type: string
              format: date
              example: '2024-03-17'
            duration_days:
              type: integer
              example: 3
        alternatives:
          type: array
          description: Alternative date ranges
          items:
            type: object
            properties:
              start_date:
                type: string
                format: date
                example: '2024-03-22'
              end_date:
                type: string
                format: date
                example: '2024-03-24'
              duration_days:
                type: integer
                example: 3
              days_from_original:
                type: integer
                description: Days difference from original start date
                example: 7
              has_weekends:
                type: boolean
                example: true
              has_holidays:
                type: boolean
                example: false
              business_days:
                type: integer
                example: 3
        total_alternatives:
          type: integer
          description: Number of alternatives found
          example: 5
        search_period:
          type: object
          description: Period searched for alternatives
          properties:
            start_date:
              type: string
              format: date
              example: '2024-03-15'
            end_date:
              type: string
              format: date
              example: '2024-05-14'
      required:
      - original_request
      - alternatives
      - total_alternatives
      - search_period
    LeaveCoverageCalendar:
      type: object
      description: Monthly calendar view of leave coverage
      properties:
        year:
          type: integer
          example: 2024
        month:
          type: integer
          example: 3
        month_name:
          type: string
          example: March
        days_in_month:
          type: integer
          example: 31
        calendar_data:
          type: array
          description: Data for each day of the month
          items:
            allOf:
            - "$ref": "#/components/schemas/LeaveCoverageAvailability"
            - type: object
              properties:
                day_of_month:
                  type: integer
                  example: 15
                day_of_week_short:
                  type: string
                  example: Fri
                is_today:
                  type: boolean
                  example: false
                holiday:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                      example: 1
                    name:
                      type: string
                      example: Good Friday
                    description:
                      type: string
                      example: Christian holiday
                blackout_period:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                      example: 1
                    name:
                      type: string
                      example: Spring Break
                    blocks_requests:
                      type: boolean
                      example: true
                week_of_month:
                  type: integer
                  example: 3
        summary:
          type: object
          description: Monthly summary statistics
          properties:
            total_days:
              type: integer
              example: 31
            available_days:
              type: integer
              example: 25
            blocked_days:
              type: integer
              example: 6
            weekend_days:
              type: integer
              example: 8
            holiday_days:
              type: integer
              example: 2
            blackout_days:
              type: integer
              example: 4
      required:
      - year
      - month
      - month_name
      - days_in_month
      - calendar_data
      - summary
    LeaveRequestHistory:
      allOf:
      - "$ref": "#/components/schemas/LeaveRequestDetailed"
      - type: object
        properties:
          status_history:
            type: array
            description: Complete status change history
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                from_status:
                  type: string
                  example: pending
                to_status:
                  type: string
                  example: approved
                changed_by:
                  type: object
                  nullable: true
                  description: Who made the change; null for an automatic change (see
                    `automatic`).
                  properties:
                    id:
                      type: integer
                      example: 2
                    name:
                      type: string
                      example: Manager Name
                automatic:
                  type: boolean
                  description: True for a change no person made — hours recounted
                    after a holiday or working-day change, or approved leave cancelled
                    when the employee left.
                  example: false
                notes:
                  type: string
                  nullable: true
                  example: Approved for vacation
                changed_at:
                  type: string
                  format: date-time
                  example: '2024-02-16T14:30:00Z'
    LeaveAnalytics:
      type: object
      description: Comprehensive leave usage analytics
      properties:
        period:
          type: object
          description: Analytics period
          properties:
            start_date:
              type: string
              format: date
              example: '2024-01-01'
            end_date:
              type: string
              format: date
              example: '2024-12-31'
            total_days:
              type: integer
              example: 366
        summary:
          type: object
          description: Overall summary statistics
          properties:
            total_requests:
              type: integer
              example: 25
            total_leave_days:
              type: integer
              example: 75
            approved_requests:
              type: integer
              example: 20
            approved_days:
              type: integer
              example: 60
            pending_requests:
              type: integer
              example: 3
            special_approval_requests:
              type: integer
              example: 1
            denied_requests:
              type: integer
              example: 1
            cancelled_requests:
              type: integer
              example: 1
        by_leave_type:
          type: array
          description: Analytics grouped by leave type
          items:
            type: object
            properties:
              leave_type:
                "$ref": "#/components/schemas/LeaveTypeBasic"
              total_requests:
                type: integer
                example: 15
              total_days:
                type: integer
                example: 45
              approved_requests:
                type: integer
                example: 12
              approved_days:
                type: integer
                example: 36
              pending_requests:
                type: integer
                example: 2
              special_approval_requests:
                type: integer
                example: 1
              denied_requests:
                type: integer
                example: 1
              cancelled_requests:
                type: integer
                example: 0
        by_month:
          type: array
          description: Analytics grouped by month
          items:
            type: object
            properties:
              month:
                type: string
                example: 2024-03
              month_name:
                type: string
                example: March 2024
              total_requests:
                type: integer
                example: 5
              total_days:
                type: integer
                example: 15
              approved_days:
                type: integer
                example: 12
        by_status:
          type: array
          description: Analytics grouped by status
          items:
            type: object
            properties:
              status:
                type: string
                example: approved
              count:
                type: integer
                example: 20
              total_days:
                type: integer
                example: 60
              percentage:
                type: number
                example: 80.0
        usage_patterns:
          type: object
          description: Usage patterns and insights
          properties:
            most_used_leave_type:
              type: object
              nullable: true
              description: Leave type with most usage
            busiest_month:
              type: object
              nullable: true
              description: Month with most leave days
            average_request_length:
              type: number
              description: Average length of leave requests in days
              example: 3.0
            longest_request:
              type: object
              nullable: true
              description: Longest leave request
            approval_rate:
              type: number
              description: Percentage of requests approved
              example: 80.0
      required:
      - period
      - summary
      - by_leave_type
      - by_month
      - by_status
      - usage_patterns
    BulkLeaveRequestResponse:
      type: object
      description: Response from bulk leave request operation
      properties:
        results:
          type: array
          description: Successful requests
          items:
            type: object
            properties:
              index:
                type: integer
                description: Index of request in original array
                example: 0
              success:
                type: boolean
                example: true
              leave_request:
                "$ref": "#/components/schemas/LeaveRequestDetailed"
        errors:
          type: array
          description: Failed requests
          items:
            type: object
            properties:
              index:
                type: integer
                description: Index of request in original array
                example: 1
              success:
                type: boolean
                example: false
              errors:
                type: array
                items:
                  type: string
                example:
                - Start date cannot be in the past
        summary:
          type: object
          description: Summary of bulk operation
          properties:
            total_requests:
              type: integer
              example: 5
            successful:
              type: integer
              example: 4
            failed:
              type: integer
              example: 1
      required:
      - results
      - errors
      - summary
    Timesheet:
      type: object
      properties:
        id:
          type: integer
          example: 123
        start_date:
          type: string
          format: date
          example: '2024-01-15'
        end_date:
          type: string
          format: date
          example: '2024-01-21'
        status:
          type: string
          enum:
          - pending
          - submitted
          - regional_pending
          - approved
          - rejected
          example: pending
        total_hours:
          type: number
          format: float
          example: 40.0
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          description: Premium hours, double time INCLUDED — overtime_only_hours plus
            double_time_hours.
          example: 0.0
        overtime_only_hours:
          type: number
          format: float
          description: Overtime hours excluding double time (the split /pay_information
            reports).
          example: 0.0
        double_time_hours:
          type: number
          format: float
          description: Double-time hours, also counted in overtime_hours.
          example: 0.0
        submission_date:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-22T09:00:00Z'
        auto_submitted:
          type: boolean
          description: True while the submission that stands was made by timesheet
            auto-submit when the due date passed — the hours went in as recorded and
            the employee did not certify them.
          example: false
        auto_submitted_at:
          type: string
          format: date-time
          nullable: true
          description: When timesheet auto-submit first submitted this sheet. Kept
            after a recall; a sheet is never auto-submitted twice.
          example:
        approval_date:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-22T14:30:00Z'
        rejection_reason:
          type: string
          nullable: true
          description: What the approver wrote when sending the timesheet back (its
            rejection reason, else the manager's notes) — the text the employee is
            shown. Null unless the timesheet is rejected.
          example: Missing Tuesday's clock-out
        returned_for_correction:
          type: boolean
          description: True when the timesheet is rejected and that rejection was
            a return to the employee for corrections (not an outright rejection).
          example: false
        correction_requested_at:
          type: string
          format: date-time
          nullable: true
          description: When the employee asked for a correction to this approved timesheet.
            Null when no request is open; returning the timesheet for correction clears
            it.
          example:
        correction_request_reason:
          type: string
          nullable: true
          description: What the employee wrote when asking for the correction. Null
            when no request is open.
          example:
        can_request_correction:
          type: boolean
          description: |
            Whether the caller may send a correction request on this timesheet
            (POST /timesheets/{id}/request_correction): their own approved
            timesheet, not managed in an external system.
          example: false
        can_answer_correction:
          type: boolean
          description: |
            Whether the caller may answer the open correction request
            (POST /timesheets/{id}/close_correction): an approver of the employee
            who is not the employee or one of their reports.
          example: false
        approved_by:
          type: string
          nullable: true
          example: John Manager
        regional_approved:
          type: boolean
          example: false
        regional_approved_by:
          type: string
          nullable: true
          example: Jane Regional
        regional_approved_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-22T16:00:00Z'
        editable:
          type: boolean
          description: Whether the timesheet can be edited
          example: true
        submittable:
          type: boolean
          description: Whether the timesheet can be submitted
          example: true
        source:
          "$ref": "#/components/schemas/ManagedSource"
        entries_count:
          type: integer
          description: Number of entries in this timesheet
          example: 5
        missing_punches_count:
          type: integer
          description: Number of entries that still need time entered (missing a clock-in
            or clock-out). Absences are not counted.
          example: 0
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T00:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-21T18:00:00Z'
    TimesheetDetailed:
      allOf:
      - "$ref": "#/components/schemas/Timesheet"
      - type: object
        properties:
          entries:
            type: array
            items:
              "$ref": "#/components/schemas/TimesheetEntry"
          entries_by_date:
            type: object
            description: Entries grouped by date
            additionalProperties:
              type: array
              items:
                "$ref": "#/components/schemas/TimesheetEntry"
          summary:
            "$ref": "#/components/schemas/TimesheetSummary"
          can_return_for_correction:
            type: boolean
            description: |
              Whether the caller may send this approved timesheet back to its
              employee (POST /timesheets/{id}/return_for_correction). False once
              its hours have been sent, or are being sent, to payroll.
            example: false
          correction_request_explainer:
            type: string
            nullable: true
            description: |
              The sentence the web and phone show above the correction request
              box — what a request does for this timesheet, including when its
              hours are already with payroll. Null when the caller cannot
              request a correction.
            example: This timesheet is approved, so it can no longer be edited directly.
              Describe what's wrong and your approver will be told; they can send
              it back to you to fix and resubmit.
    TimesheetEntry:
      type: object
      properties:
        id:
          type: integer
          example: 456
        date:
          type: string
          format: date
          example: '2024-01-15'
        start_time:
          type: string
          format: time
          nullable: true
          example: '09:00:00'
        end_time:
          type: string
          format: time
          nullable: true
          example: '17:00:00'
        hours:
          type: number
          format: float
          nullable: true
          example: 8.0
        edited:
          type: boolean
          description: Whether this entry has been manually edited
          example: false
        edit_notes:
          type: string
          nullable: true
          description: Notes about edits made to this entry
          example: Corrected clock-in time
        missing_punch:
          type: boolean
          description: Whether this past entry still needs a clock-in or clock-out
            entered. False for an absence (see absent).
          example: false
        absent:
          type: boolean
          description: Whether this entry is an absence — a scheduled day with no
            times and no hours — which has nothing to enter.
          example: false
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
        attendance_record_id:
          type: integer
          nullable: true
          description: Associated attendance record ID
          example: 789
        shift_id:
          type: integer
          nullable: true
          description: Associated shift ID
          example: 101
        shift:
          type: object
          nullable: true
          description: Associated shift information
          properties:
            id:
              type: integer
              example: 101
            title:
              type: string
              example: Morning Shift
            location:
              type: string
              nullable: true
              example: Main Office
            is_ad_hoc:
              type: boolean
              example: false
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T09:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T17:00:00Z'
    TimesheetEntryDetailed:
      allOf:
      - "$ref": "#/components/schemas/TimesheetEntry"
      - type: object
        properties:
          timesheet:
            type: object
            description: Associated timesheet information
            properties:
              id:
                type: integer
                example: 123
              start_date:
                type: string
                format: date
                example: '2024-01-15'
              end_date:
                type: string
                format: date
                example: '2024-01-21'
              status:
                type: string
                example: pending
          edit_history:
            type: array
            description: History of edits made to this entry
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                field_changed:
                  type: string
                  example: start_time
                original_value:
                  type: string
                  example: '08:45:00'
                new_value:
                  type: string
                  example: '09:00:00'
                reason:
                  type: string
                  example: Employee correction
                edited_by:
                  type: string
                  example: John Employee
                edited_at:
                  type: string
                  format: date-time
                  example: '2024-01-15T10:00:00Z'
          attendance_record:
            type: object
            nullable: true
            description: Associated attendance record details
            properties:
              id:
                type: integer
                example: 789
              check_in_time:
                type: string
                format: date-time
                nullable: true
                example: '2024-01-15T09:00:00Z'
              check_out_time:
                type: string
                format: date-time
                nullable: true
                example: '2024-01-15T17:00:00Z'
              status:
                type: string
                example: completed
              requires_review:
                type: boolean
                example: false
    TimesheetSummary:
      type: object
      properties:
        total_hours:
          type: number
          format: float
          example: 40.0
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          description: Premium hours, double time INCLUDED — overtime_only_hours plus
            double_time_hours.
          example: 0.0
        overtime_only_hours:
          type: number
          format: float
          description: Overtime hours excluding double time.
          example: 0.0
        double_time_hours:
          type: number
          format: float
          description: Double-time hours, also counted in overtime_hours.
          example: 0.0
        total_entries:
          type: integer
          example: 5
        missing_punches:
          type: integer
          description: Entries that still need time entered. Absences are not counted.
          example: 0
        edited_entries:
          type: integer
          example: 1
        days_with_entries:
          type: integer
          example: 5
    PaySummary:
      type: object
      properties:
        pay_period:
          type: object
          properties:
            start_date:
              type: string
              format: date
              example: '2024-01-15'
            end_date:
              type: string
              format: date
              example: '2024-01-21'
            period_type:
              type: string
              enum:
              - weekly
              - bi-weekly
              - semi-monthly
              - monthly
              example: weekly
        hours:
          type: object
          properties:
            regular_hours:
              type: number
              format: float
              example: 40.0
            overtime_hours:
              type: number
              format: float
              example: 2.5
            total_hours:
              type: number
              format: float
              example: 42.5
        estimated_pay:
          type: object
          properties:
            regular_pay:
              type: number
              format: float
              example: 1000.0
            overtime_pay:
              type: number
              format: float
              example: 93.75
            total_pay:
              type: number
              format: float
              example: 1093.75
            hourly_rate:
              type: number
              format: float
              example: 25.0
            overtime_rate:
              type: number
              format: float
              example: 37.5
        timesheet_status:
          type: string
          enum:
          - not_created
          - pending
          - submitted
          - approved
          - rejected
          example: pending
        last_updated:
          type: string
          format: date-time
          example: '2024-01-20T10:30:00Z'
    PayHistoryItem:
      type: object
      properties:
        type:
          type: string
          enum:
          - timesheet
          - paycheck
          example: timesheet
        id:
          type: integer
          example: 123
        period_start:
          type: string
          format: date
          example: '2024-01-15'
        period_end:
          type: string
          format: date
          example: '2024-01-21'
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          example: 2.5
        total_hours:
          type: number
          format: float
          example: 42.5
        status:
          type: string
          description: Status (for timesheets)
          example: approved
        approved_at:
          type: string
          format: date-time
          description: Approval date (for timesheets)
          example: '2024-01-22T14:30:00Z'
        estimated_pay:
          type: number
          format: float
          description: Estimated pay (for timesheets)
          example: 1093.75
        gross_pay:
          type: number
          format: float
          description: Gross pay (for paychecks)
          example: 1093.75
        net_pay:
          type: number
          format: float
          description: Net pay (for paychecks)
          example: 850.0
        pay_date:
          type: string
          format: date
          description: Pay date (for paychecks)
          example: '2024-01-26'
    PayPeriodDetailed:
      type: object
      properties:
        id:
          type: string
          example: 2024-01-15_2024-01-21
        start_date:
          type: string
          format: date
          example: '2024-01-15'
        end_date:
          type: string
          format: date
          example: '2024-01-21'
        period_type:
          type: string
          enum:
          - weekly
          - bi-weekly
          - semi-monthly
          - monthly
          example: weekly
        days_in_period:
          type: integer
          example: 7
        is_current:
          type: boolean
          example: true
        is_future:
          type: boolean
          example: false
        is_past:
          type: boolean
          example: false
        payday:
          type: string
          format: date
          example: '2024-01-26'
        days_until_payday:
          type: integer
          description: Days until payday (for current period)
          example: 5
        days_remaining:
          type: integer
          description: Days remaining in period (for current period)
          example: 2
        timesheet:
          type: object
          properties:
            id:
              type: integer
              example: 123
            status:
              type: string
              enum:
              - pending
              - submitted
              - approved
              - rejected
              example: pending
            total_hours:
              type: number
              format: float
              example: 42.5
            regular_hours:
              type: number
              format: float
              example: 40.0
            overtime_hours:
              type: number
              format: float
              example: 2.5
            submission_date:
              type: string
              format: date-time
              example: '2024-01-21T17:00:00Z'
            approval_date:
              type: string
              format: date-time
              example: '2024-01-22T09:00:00Z'
            editable:
              type: boolean
              example: true
            submittable:
              type: boolean
              example: true
        estimated_pay:
          type: number
          format: float
          example: 1093.75
        work_days:
          type: integer
          example: 5
    NextPayday:
      type: object
      properties:
        date:
          type: string
          format: date
          example: '2024-01-26'
        days_until:
          type: integer
          example: 5
        period_start:
          type: string
          format: date
          example: '2024-01-15'
        period_end:
          type: string
          format: date
          example: '2024-01-21'
    YTDSummary:
      type: object
      properties:
        year:
          type: integer
          example: 2024
        period:
          type: object
          properties:
            start_date:
              type: string
              format: date
              example: '2024-01-01'
            end_date:
              type: string
              format: date
              example: '2024-12-31'
            days_elapsed:
              type: integer
              example: 20
            days_remaining:
              type: integer
              example: 345
        hours:
          type: object
          properties:
            regular_hours:
              type: number
              format: float
              example: 160.0
            overtime_hours:
              type: number
              format: float
              example: 10.0
            total_hours:
              type: number
              format: float
              example: 170.0
            average_weekly_hours:
              type: number
              format: float
              example: 42.5
        estimated_pay:
          type: object
          properties:
            regular_pay:
              type: number
              format: float
              example: 4000.0
            overtime_pay:
              type: number
              format: float
              example: 375.0
            total_pay:
              type: number
              format: float
              example: 4375.0
        actual_pay:
          type: object
          properties:
            gross_pay:
              type: number
              format: float
              example: 4200.0
            net_pay:
              type: number
              format: float
              example: 3200.0
            paychecks_count:
              type: integer
              example: 4
        timesheets:
          type: object
          properties:
            total_count:
              type: integer
              example: 4
            approved_count:
              type: integer
              example: 3
    Paycheck:
      type: object
      properties:
        id:
          type: integer
          example: 123
        pay_date:
          type: string
          format: date
          example: '2024-01-26'
        pay_period_start:
          type: string
          format: date
          example: '2024-01-15'
        pay_period_end:
          type: string
          format: date
          example: '2024-01-21'
        regular_hours:
          type: number
          format: float
          example: 40.0
        overtime_hours:
          type: number
          format: float
          example: 2.5
        total_hours:
          type: number
          format: float
          example: 42.5
        gross_pay:
          type: number
          format: float
          example: 1093.75
        net_pay:
          type: number
          format: float
          example: 850.0
        pay_frequency:
          type: string
          enum:
          - weekly
          - bi-weekly
          - semi-monthly
          - monthly
          example: weekly
        has_file:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2024-01-26T08:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-26T08:00:00Z'
    PaycheckDetailed:
      allOf:
      - "$ref": "#/components/schemas/Paycheck"
      - type: object
        properties:
          earnings:
            type: object
            properties:
              regular_pay:
                type: number
                format: float
                example: 1000.0
              overtime_pay:
                type: number
                format: float
                example: 93.75
              holiday_pay:
                type: number
                format: float
                example: 0.0
              sick_pay:
                type: number
                format: float
                example: 0.0
              vacation_pay:
                type: number
                format: float
                example: 0.0
              bonus:
                type: number
                format: float
                example: 0.0
              commission:
                type: number
                format: float
                example: 0.0
              other_earnings:
                type: number
                format: float
                example: 0.0
              gross_pay:
                type: number
                format: float
                example: 1093.75
          deductions:
            type: object
            properties:
              health_insurance:
                type: number
                format: float
                example: 125.0
              dental_insurance:
                type: number
                format: float
                example: 15.0
              vision_insurance:
                type: number
                format: float
                example: 5.0
              life_insurance:
                type: number
                format: float
                example: 10.0
              retirement_401k:
                type: number
                format: float
                example: 50.0
              retirement_roth:
                type: number
                format: float
                example: 0.0
              hsa:
                type: number
                format: float
                example: 25.0
              fsa:
                type: number
                format: float
                example: 0.0
              parking:
                type: number
                format: float
                example: 20.0
              union_dues:
                type: number
                format: float
                example: 0.0
              other_deductions:
                type: number
                format: float
                example: 0.0
              total_deductions:
                type: number
                format: float
                example: 250.0
          taxes:
            type: object
            properties:
              federal_income_tax:
                type: number
                format: float
                example: 150.0
              state_income_tax:
                type: number
                format: float
                example: 50.0
              local_income_tax:
                type: number
                format: float
                example: 10.0
              social_security:
                type: number
                format: float
                example: 67.81
              medicare:
                type: number
                format: float
                example: 15.86
              unemployment_tax:
                type: number
                format: float
                example: 0.0
              disability_tax:
                type: number
                format: float
                example: 5.0
              other_taxes:
                type: number
                format: float
                example: 0.0
              total_taxes:
                type: number
                format: float
                example: 298.67
          employer_contributions:
            type: object
            properties:
              health_insurance:
                type: number
                format: float
                example: 200.0
              retirement_match:
                type: number
                format: float
                example: 25.0
              social_security:
                type: number
                format: float
                example: 67.81
              medicare:
                type: number
                format: float
                example: 15.86
              unemployment:
                type: number
                format: float
                example: 6.56
              workers_comp:
                type: number
                format: float
                example: 10.94
              other_contributions:
                type: number
                format: float
                example: 0.0
              total_contributions:
                type: number
                format: float
                example: 326.17
          year_to_date:
            type: object
            properties:
              gross_pay:
                type: number
                format: float
                example: 4375.0
              net_pay:
                type: number
                format: float
                example: 3400.0
              regular_hours:
                type: number
                format: float
                example: 160.0
              overtime_hours:
                type: number
                format: float
                example: 10.0
              total_hours:
                type: number
                format: float
                example: 170.0
              federal_tax:
                type: number
                format: float
                example: 600.0
              state_tax:
                type: number
                format: float
                example: 200.0
              social_security:
                type: number
                format: float
                example: 271.25
              medicare:
                type: number
                format: float
                example: 63.44
          connection:
            type: object
            properties:
              id:
                type: integer
                example: 1
              name:
                type: string
                example: ADP Payroll Connection
              provider:
                type: string
                example: adp
    PayPeriod:
      type: object
      properties:
        id:
          type: string
          example: 2024-01-15_2024-01-21
        start_date:
          type: string
          format: date
          example: '2024-01-15'
        end_date:
          type: string
          format: date
          example: '2024-01-21'
        period_type:
          type: string
          enum:
          - weekly
          - bi-weekly
          - semi-monthly
          - monthly
          example: weekly
        days_in_period:
          type: integer
          example: 7
        is_current:
          type: boolean
          example: true
        is_future:
          type: boolean
          example: false
        is_past:
          type: boolean
          example: false
    PayPeriodSummary:
      type: object
      properties:
        period:
          "$ref": "#/components/schemas/PayPeriod"
        hours:
          type: object
          properties:
            timesheet:
              type: object
              properties:
                regular:
                  type: number
                  format: float
                  example: 40.0
                overtime:
                  type: number
                  format: float
                  example: 2.5
                total:
                  type: number
                  format: float
                  example: 42.5
            attendance:
              type: object
              properties:
                regular:
                  type: number
                  format: float
                  example: 39.5
                overtime:
                  type: number
                  format: float
                  example: 2.0
                total:
                  type: number
                  format: float
                  example: 41.5
            scheduled:
              type: number
              format: float
              example: 40.0
            variance:
              type: object
              properties:
                timesheet_vs_scheduled:
                  type: number
                  format: float
                  example: 2.5
                attendance_vs_scheduled:
                  type: number
                  format: float
                  example: 1.5
                timesheet_vs_attendance:
                  type: number
                  format: float
                  example: 1.0
        attendance:
          type: object
          properties:
            total_records:
              type: integer
              example: 5
            completed_shifts:
              type: integer
              example: 5
            missed_shifts:
              type: integer
              example: 0
            late_arrivals:
              type: integer
              example: 1
            early_departures:
              type: integer
              example: 0
        estimated_pay:
          type: number
          format: float
          example: 1093.75
        timesheet_status:
          type: string
          enum:
          - not_created
          - pending
          - submitted
          - approved
          - rejected
          example: pending
        completion_percentage:
          type: number
          format: float
          example: 85.7
    ManualTimeEntry:
      type: object
      properties:
        id:
          type: integer
          example: 456
        timesheet_id:
          type: integer
          example: 123
        date:
          type: string
          format: date
          example: '2024-01-15'
        start_time:
          type: string
          format: time
          example: '09:00:00'
        end_time:
          type: string
          format: time
          example: '17:00:00'
        hours:
          type: number
          format: float
          example: 8.0
        entry_type:
          type: string
          enum:
          - missing_punch
          - regular
          - overtime
          example: missing_punch
        edit_notes:
          type: string
          example: Manual entry for missed punch
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          example: pending
        shift:
          type: object
          properties:
            id:
              type: integer
              example: 789
            title:
              type: string
              example: Morning Shift
            location:
              type: string
              example: Main Office
        timesheet:
          type: object
          properties:
            id:
              type: integer
              example: 123
            start_date:
              type: string
              format: date
              example: '2024-01-15'
            end_date:
              type: string
              format: date
              example: '2024-01-21'
            status:
              type: string
              enum:
              - pending
              - submitted
              - approved
              - rejected
              example: pending
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T18:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T18:00:00Z'
    ManualTimeEntryDetailed:
      allOf:
      - "$ref": "#/components/schemas/ManualTimeEntry"
      - type: object
        properties:
          edit_history:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 1
                field_changed:
                  type: string
                  example: end_time
                original_value:
                  type: string
                  example: '16:30:00'
                new_value:
                  type: string
                  example: '17:00:00'
                reason:
                  type: string
                  example: Employee requested time correction
                edited_by:
                  type: string
                  example: John Doe
                edited_at:
                  type: string
                  format: date-time
                  example: '2024-01-16T09:00:00Z'
          validation_warnings:
            type: array
            items:
              type: object
              properties:
                type:
                  type: string
                  enum:
                  - overlap
                  - excessive_hours
                  - weekend_work
                  example: overlap
                message:
                  type: string
                  example: This entry overlaps with another time entry on the same
                    date
          pay_calculation:
            type: object
            nullable: true
            description: Null when the entry has no hours. Pay is at the caller's
              own rate on file (hourly rate, or annual salary / 2080); when no rate
              is on file the hours are still given and every pay and rate field is
              null.
            properties:
              regular_hours:
                type: number
                format: float
                example: 8.0
              overtime_hours:
                type: number
                format: float
                example: 0.0
              regular_pay:
                type: number
                format: float
                nullable: true
                example: 200.0
              overtime_pay:
                type: number
                format: float
                nullable: true
                example: 0.0
              total_pay:
                type: number
                format: float
                nullable: true
                example: 200.0
              hourly_rate:
                type: number
                format: float
                nullable: true
                example: 25.0
              overtime_rate:
                type: number
                format: float
                nullable: true
                example: 37.5
    Skill:
      type: object
      properties:
        id:
          type: integer
          example: 123
        name:
          type: string
          example: JavaScript Development
        description:
          type: string
          nullable: true
          example: Frontend and backend JavaScript programming
        category:
          type: string
          nullable: true
          example: technical_skills
        category_display:
          type: string
          nullable: true
          example: Technical Skills
        requires_certification:
          type: boolean
          example: true
        skill_kind:
          type: string
          enum:
          - skill
          - certification
          - license
          description: 'Catalog credential type. Clients map `license` -> LIC badge,
            `certification` -> CERT badge, `skill` -> no badge.

            '
          example: certification
        requires_document_upload:
          type: boolean
          example: false
        active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
    SkillDetailed:
      allOf:
      - "$ref": "#/components/schemas/Skill"
      - type: object
        properties:
          metadata:
            type: object
            nullable: true
            description: Additional skill metadata
          help_desk_tier:
            type: integer
            nullable: true
            example: 2
          employee_count:
            type: integer
            example: 15
          average_proficiency:
            type: number
            format: float
            nullable: true
            example: 3.4
    EmployeeSkill:
      type: object
      properties:
        id:
          type: integer
          example: 456
        skill_id:
          type: integer
          example: 123
        proficiency_level:
          type: integer
          minimum: 1
          maximum: 5
          example: 3
        proficiency_text:
          type: string
          description: The level's name as the organization calls it (Skills & Credentials
            › Settings › Proficiency Levels), the platform's default names where it
            renamed none. Sent on every item of the list, bulk and expiring endpoints
            as well as show.
          example: Intermediate
        certification_date:
          type: string
          format: date
          nullable: true
          example: '2024-01-15'
        expiration_date:
          type: string
          format: date
          nullable: true
          example: '2025-01-15'
        expiring_soon:
          type: boolean
          description: True when the expiration date falls between today and the organization's
            alert lead time for expiring credentials (Skills & Credentials › Settings
            › Notifications, "Credential expiring soon"). False with no expiration
            date or once expired.
          example: true
        certification_number:
          type: string
          nullable: true
          example: CERT-2024-001
        notes:
          type: string
          nullable: true
          example: Completed advanced course
        verified:
          type: boolean
          example: true
        verified_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-16T10:00:00Z'
        pending_upload:
          type: boolean
          description: 'A new certificate the employee uploaded while this credential
            is still valid, waiting for a manager''s review. The credential itself
            stays verified and live meanwhile, so `verification_status=pending` can
            return rows with `verified: true`.'
          example: false
        active:
          type: boolean
          example: true
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        skill:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: JavaScript Development
            category:
              type: string
              nullable: true
              example: technical_skills
            category_display:
              type: string
              nullable: true
              example: Technical Skills
            requires_certification:
              type: boolean
              example: true
            skill_kind:
              type: string
              enum:
              - skill
              - certification
              - license
              description: Raw catalog credential type. Drives the LIC/CERT badge
                in the web/mobile UI — clients map license -> "LIC", certification
                -> "CERT", skill -> no badge.
              example: certification
        detected:
          type: boolean
          description: Automatic skill detection added it (`source` is "inferred");
            the web and phone mark it Detected.
          example: true
        source:
          type: string
          description: How the holding was added, as stored ("inferred", "manual",
            "self_reported", "external", …).
          example: inferred
        disputed:
          type: boolean
          description: Only on the caller's own holdings. They said "Not mine" and
            a reviewer has not decided yet — the holding is then inactive and unverified,
            which otherwise reads like a rejection.
          example: false
        not_mine_available:
          type: boolean
          description: 'Only on the caller''s own holdings. POST /employee_skills/{id}/not_mine
            would take it off now: detected, active, its detection disputable, Skill
            Reviews (Competency Assessment) open, and a token that may write skills
            (`write:skills_certifications`, its own form included).'
          example: true
    EmployeeSkillDetailed:
      allOf:
      - "$ref": "#/components/schemas/EmployeeSkill"
      - type: object
        properties:
          verifier:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 789
              name:
                type: string
                example: John Manager
          days_until_expiration:
            type: integer
            nullable: true
            example: 45
          expired:
            type: boolean
            example: false
          verification_status:
            type: string
            description: "`ended`: a manager took it off the record; it is kept as
              history and held on no date from then on."
            enum:
            - verified
            - pending
            - unverified
            - ended
            example: verified
          current_certification:
            nullable: true
            allOf:
            - "$ref": "#/components/schemas/CertificationPeriod"
          certification_history:
            type: array
            description: 'Every award of this credential, newest first: each course,
              learning path, renewal or upload that awarded it is its own period.'
            items:
              "$ref": "#/components/schemas/CertificationPeriod"
    CertificationPeriod:
      type: object
      description: One award of a credential and the dates it was valid.
      properties:
        id:
          type: integer
          example: 9876
        status:
          type: string
          enum:
          - active
          - superseded
          - expired
          - revoked
          - declined
          - pending
          - rejected
          description: active = the credential in force; superseded = replaced by
            a renewal or a later or longer award (history); pending = an upload waiting
            for review while a valid credential is held; rejected = an upload a reviewer
            refused; declined = a claim a reviewer refused.
          example: active
        status_label:
          type: string
          description: The status in the words the web app shows (Active, Superseded,
            Expired, Revoked, Declined, Pending verification).
          example: Superseded
        certification_date:
          type: string
          format: date
          nullable: true
          example: '2024-01-15'
        expiration_date:
          type: string
          format: date
          nullable: true
          example: '2025-01-15'
        certification_number:
          type: string
          nullable: true
          example: CERT-2024-001
        jurisdiction:
          type: string
          nullable: true
          description: The jurisdiction (state or province) that issued a licence.
          example: California
        license_compact:
          type: string
          nullable: true
          enum:
          - nlc
          description: The compact a multistate licence is issued under (`nlc`, the
            Nurse Licensure Compact); null when single-state.
        ended_on:
          type: string
          format: date
          nullable: true
          description: The day this period stopped counting before its own expiry
            date — the credential was ended or the issuer revoked it.
          example: '2026-09-01'
        revoked_reason:
          type: string
          nullable: true
          description: Why the issuer revoked it, as recorded.
        issuing_authority:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              nullable: true
              example: 12
            name:
              type: string
              example: State Licensing Board
        credential_url:
          type: string
          nullable: true
        verification_link:
          type: string
          nullable: true
        ce_credits:
          type: number
          nullable: true
        source:
          type: string
          nullable: true
          example: training
        awarded_by_label:
          type: string
          description: What awarded this period, in words (a course, a learning path,
            an upload, a renewal).
          example: 'Course: Forklift Safety'
        source_course:
          type: object
          nullable: true
          description: The course whose completion awarded this period.
          properties:
            id:
              type: integer
              example: 321
            title:
              type: string
              example: Forklift Safety
        source_learning_path:
          type: object
          nullable: true
          description: The learning path whose completion awarded this period.
          properties:
            id:
              type: integer
              example: 55
            title:
              type: string
              example: Warehouse Onboarding
        renewed_from_id:
          type: integer
          nullable: true
          description: The period this award replaced.
        verified:
          type: boolean
          example: true
    Certification:
      type: object
      properties:
        id:
          type: integer
          example: 456
        skill_id:
          type: integer
          example: 123
        certification_date:
          type: string
          format: date
          example: '2024-01-15'
        expiration_date:
          type: string
          format: date
          nullable: true
          example: '2025-01-15'
        certification_number:
          type: string
          nullable: true
          example: CERT-2024-001
        verified:
          type: boolean
          example: true
        verified_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-16T10:00:00Z'
        created_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:00:00Z'
        skill:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: JavaScript Development
            category:
              type: string
              nullable: true
              example: technical_skills
            category_display:
              type: string
              nullable: true
              example: Technical Skills
    CertificationDetailed:
      allOf:
      - "$ref": "#/components/schemas/Certification"
      - type: object
        properties:
          notes:
            type: string
            nullable: true
            example: Completed advanced course
          proficiency_level:
            type: integer
            minimum: 1
            maximum: 5
            example: 3
          verifier:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 789
              name:
                type: string
                example: John Manager
          days_until_expiration:
            type: integer
            nullable: true
            example: 45
          expired:
            type: boolean
            example: false
          expiring_soon:
            type: boolean
            example: true
          status:
            type: string
            description: "`ended`: a manager took it off the record; kept as history."
            enum:
            - active
            - expiring_soon
            - expiring_notice
            - expired
            - ended
            example: active
          document_attached:
            type: boolean
            example: true
    LoginConfiguration:
      type: object
      properties:
        business:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: Office Chat Solutions
            subdomain:
              type: string
              example: officechat
            logo_url:
              type: string
              nullable: true
              example: https://cdn.workforce.mangoapps.com/logos/officechat.png
            timezone:
              type: string
              example: America/New_York
            branding:
              "$ref": "#/components/schemas/Business/properties/branding"
        login_methods:
          type: object
          properties:
            email_password:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                forgot_password_enabled:
                  type: boolean
                  example: true
            sso_providers:
              type: array
              description: |
                Array of configured SSO providers for this business. Multiple providers of the same
                type (e.g., multiple Google OAuth2 configurations) are supported. Each provider has
                a unique ID that should be passed to the SSO initiate endpoint.
              items:
                type: object
                properties:
                  id:
                    type: string
                    description: Unique identifier for this SSO configuration (use
                      as provider_id)
                    example: '67'
                  provider_type:
                    type: string
                    enum:
                    - google_oauth2
                    - saml
                    - entra_id
                    - mangoapps
                    description: The underlying technology/provider type
                    example: google_oauth2
                  provider_name:
                    type: string
                    description: User-friendly name for this SSO provider
                    example: Google Workspace - Partners
                  enabled:
                    type: boolean
                    description: Whether this SSO configuration is currently active
                    example: true
                  logo_url:
                    type: string
                    nullable: true
                    description: URL to the provider's logo for display in UI
                    example: https://cdn.workforce.mangoapps.com/logos/google.png
                  description:
                    type: string
                    description: A short description for the SSO provider
                    example: Sign in with your Google Workspace account
                  channel_support:
                    type: string
                    enum:
                    - web_only
                    - mobile_only
                    - both
                    description: Indicates which channels (web, mobile, or both) this
                      SSO configuration supports
                    example: both
                  mobile_supported:
                    type: boolean
                    description: Derived flag indicating if this SSO configuration
                      is available for mobile apps
                    example: true
                  web_supported:
                    type: boolean
                    description: Derived flag indicating if this SSO configuration
                      is available for web browsers
                    example: true
            passwordless:
              type: object
              description: |
                Passwordless sign-in through `POST /auth/passwordless/request`. Always disabled:
                that endpoint delivers no link or code and answers `403 feature_disabled`, so a
                client must not offer it. The keys are kept so existing decoders still read the block.
              properties:
                enabled:
                  type: boolean
                  example: false
                methods:
                  type: array
                  items:
                    type: string
                    enum:
                    - magic_link
                    - sms_code
                  example: []
                magic_link:
                  type: object
                  properties:
                    enabled:
                      type: boolean
                      example: false
                    expires_in_minutes:
                      type: integer
                      example: 15
                sms_code:
                  type: object
                  properties:
                    enabled:
                      type: boolean
                      example: false
                    expires_in_minutes:
                      type: integer
                      example: 5
        two_factor_authentication:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            methods:
              type: array
              items:
                type: string
                enum:
                - sms
                - email
                - authenticator_app
                - backup_codes
              example:
              - sms
              - authenticator_app
            backup_codes:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                count:
                  type: integer
                  example: 8
        biometric_authentication:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            supported_methods:
              type: array
              items:
                type: string
                enum:
                - face_id
                - touch_id
                - fingerprint
              example:
              - face_id
              - touch_id
              - fingerprint
        remember_device:
          type: object
          properties:
            enabled:
              type: boolean
              example: true
            duration_days:
              type: integer
              example: 30
        api_endpoints:
          type: object
          properties:
            login:
              type: string
              example: "/api/v1/auth/login"
            sso_initiate:
              type: string
              example: "/api/v1/auth/sso/initiate"
            sso_exchange:
              type: string
              example: "/api/v1/auth/sso/exchange"
            refresh_token:
              type: string
              example: "/api/v1/auth/refresh"
            logout:
              type: string
              example: "/api/v1/auth/logout"
            passwordless_request:
              type: string
              example: "/api/v1/auth/passwordless/request"
            passwordless_verify:
              type: string
              example: "/api/v1/auth/passwordless/verify"
    SsoInitiateResponse:
      type: object
      properties:
        authorization_url:
          type: string
          description: URL to open for SSO authentication
          example: https://accounts.google.com/o/oauth2/v2/auth?client_id=...
        state:
          type: string
          description: State parameter for security verification
          example: abc123def456
        app_redirect_uri:
          type: string
          nullable: true
          description: |
            Echo of the accepted `app_redirect_uri`, or null when the submitted value was
            dropped by the allowlist. Desktop clients MUST compare this against what they
            sent: a value that does not come back will never be reached by the callback
            page, so fall back to a custom scheme BEFORE opening the browser rather than
            waiting out a sign-in that cannot complete. Servers predating this field omit
            it entirely, which clients must also read as "not accepted".
          example: http://127.0.0.1:52341/sso-callback
        provider:
          type: object
          properties:
            type:
              type: string
              example: google_oauth2
            name:
              type: string
              example: Google Workspace
        expires_in:
          type: integer
          description: State expiration time in seconds
          example: 600
        instructions:
          type: object
          properties:
            message:
              type: string
              example: Open the authorization URL in a web view or external browser
            next_step:
              type: string
              example: After authorization, exchange the received code using the /exchange
                endpoint
    SsoExchangeResponse:
      type: object
      properties:
        access_token:
          type: string
          description: Access token for API requests
          example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
        refresh_token:
          type: string
          description: Refresh token for token renewal
          example: def456ghi789...
        expires_in:
          type: integer
          description: Access token expiration time in seconds
          example: 3600
        token_type:
          type: string
          example: Bearer
        user:
          type: object
          description: The person who signed in (Api::V1::Auth::SsoController#user_response).
          properties:
            id:
              type: integer
            email:
              type: string
              nullable: true
            first_name:
              type: string
              nullable: true
            last_name:
              type: string
              nullable: true
            full_name:
              type: string
            avatar_url:
              type: string
              nullable: true
            timezone:
              type: string
              description: |
                The zone they read — their own choice, else their place's, else
                the company's — as stored: usually a Rails name, sometimes an IANA id.
              example: Central Time (US & Canada)
            time_zone_iana:
              type: string
              nullable: true
              description: |
                The same zone as a canonical IANA id, for Intl and moment-timezone,
                which know no Rails name. Null only when the zone can't be resolved.
              example: America/Chicago
        business:
          "$ref": "#/components/schemas/Business"
        sso_provider:
          type: object
          properties:
            type:
              type: string
              example: google_oauth2
            name:
              type: string
              example: Google Workspace
    EnhancedUser:
      type: object
      description: Enhanced user profile with additional security and metadata
      properties:
        id:
          type: integer
          example: 123
        email:
          type: string
          format: email
          example: john.doe@example.com
        first_name:
          type: string
          example: John
        last_name:
          type: string
          example: Doe
        full_name:
          type: string
          example: John Doe
        role:
          type: string
          example: Employee
        active:
          type: boolean
          example: true
        avatar_url:
          type: string
          nullable: true
          example: https://example.com/avatar.jpg
        phone:
          type: string
          nullable: true
          example: "+1234567890"
        language:
          type: string
          description: |
            The language content reaches this person in — their own setting,
            else their place's language, else the company default. Never null
            (Knowledge Layer P4); `language_set` says whether they chose it.
          example: en
        language_set:
          type: boolean
          description: |
            Whether the person chose a language themselves. `false` is the
            profile's "Not set": `language` then follows their place, else the
            company default.
          example: false
        timezone:
          type: string
          nullable: true
          description: |
            The zone the person reads — their own choice, else their place's,
            else the company's — as stored: usually a Rails name, sometimes an
            IANA id.
          example: America/New_York
        time_zone_iana:
          type: string
          nullable: true
          description: |
            The same zone as a canonical IANA id, for Intl and moment-timezone,
            which know no Rails name. Null only when the zone can't be resolved.
          example: America/New_York
        timezone_set:
          type: boolean
          description: Whether the person chose a zone themselves.
        employee_type:
          type: string
          nullable: true
          example: Full-Time
        hire_date:
          type: string
          format: date
          nullable: true
          example: '2023-01-15'
        last_activity_at:
          type: string
          format: date-time
          nullable: true
          example: '2024-01-15T10:30:00Z'
        created_at:
          type: string
          format: date-time
          example: '2023-01-15T09:00:00Z'
        updated_at:
          type: string
          format: date-time
          example: '2024-01-15T10:30:00Z'
    WaitingOnYouAcknowledgeItem:
      type: object
      description: |
        One "To acknowledge" row of Waiting on you — something the caller was
        asked to confirm and has not (Comms::Obligations::Item#to_payload).
        `asked_at` is when THIS person was asked (for a must-read post or a
        Sites page, the day they joined its audience — a new hire added to an
        old post was asked the day they were added); `due_at` is the thing's
        own deadline. `asked_at` later than `due_at` means the person was asked
        only after the deadline had passed.
      properties:
        kind:
          type: string
          enum:
          - must_read_post
          - broadcast
          - alert
          - site_page
          - policy
          - policy_campaign
          - sop
          - job_description
          - execution_post
          - chat_message
        id:
          type: integer
          description: The post, broadcast, alert, page, policy, campaign package,
            SOP, job description, execution post or chat message id
        title:
          type: string
        kind_label:
          type: string
        url:
          type: string
          description: The /m/ page where this is confirmed
        asked_at:
          type: string
          format: date-time
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
        action_label:
          type: string
        open_label:
          type: string
          description: The words for a button that OPENS the item — "Read and acknowledge"
            for a must-read post or broadcast (acknowledged on its own page, after
            it is read), else `action_label`
        confirm_in_chat:
          type: boolean
          description: Whether Ask AI's confirm_item can confirm this kind (must-read
            posts and broadcasts only)
    WaitingOnYouDecideRow:
      type: object
      description: |
        One "To decide" row of Waiting on you (Knowledge Layer E2) — a request
        or suggestion whose decision is the caller's, from the approvals
        registry. Decided with `POST /api/v1/home/waiting_on_you/decide`
        (`source`, `id`; Knowledge Layer C3) or where `url` links;
        `can_approve` / `can_decline` say which verbs will be taken right now —
        from an API token, what that decide door takes from it: neither without
        `write:approvals`, and no delete-level verdict without its area's delete
        scope (`GET /home` and the clock-in brief alike).
      properties:
        source:
          type: string
          description: The approvals source (queue) key.
          example: leave_requests
        id:
          type: integer
          description: The record's id in that source.
        kind_label:
          type: string
          example: Time off
        title:
          type: string
          description: Subject and summary as one line.
          example: Ana Morales — Vacation · 9 Oct – 10 Oct
        subtitle:
          type: string
          nullable: true
        requested_at:
          type: string
          format: date-time
          nullable: true
        urgency:
          type: string
          enum:
          - overdue
          - soon
          - normal
        urgency_label:
          type: string
          nullable: true
          description: The words for `urgency` ("Overdue", "Due soon", or the source's
            own); null for `normal`.
        approve_label:
          type: string
          example: Approve
        decline_label:
          type: string
          example: Decline
        can_approve:
          type: boolean
        can_decline:
          type: boolean
        decline_needs_reason:
          type: boolean
        approve_question:
          type: string
          nullable: true
          description: A question to ask the person before sending `approve` (for
            example when they are approving their own request); null when Approve
            asks nothing.
        decline_question:
          type: string
          nullable: true
          description: A question to ask before sending `decline` (a refusal that
            takes no reason and cannot be undone); null when Decline asks nothing.
        url:
          type: string
          nullable: true
          description: Where it is decided (the /m/ page on the API).
    SecurityInfo:
      type: object
      description: User security information and status
      properties:
        two_factor_enabled:
          type: boolean
          description: Whether 2FA is enabled for the user
          example: true
        two_factor_status:
          type: string
          enum:
          - disabled
          - pending_setup
          - pending_confirmation
          - enabled
          description: Current 2FA setup status
          example: enabled
        account_locked:
          type: boolean
          description: Whether the account is currently locked
          example: false
        last_sign_in_at:
          type: string
          format: date-time
          nullable: true
          description: Last successful sign-in timestamp
          example: '2024-01-15T08:30:00Z'
        last_sign_in_ip:
          type: string
          nullable: true
          description: IP address of last sign-in
          example: 192.168.1.100
        sign_in_count:
          type: integer
          description: Total number of sign-ins
          example: 42
        failed_attempts:
          type: integer
          description: Number of failed login attempts
          example: 0
        locked_at:
          type: string
          format: date-time
          nullable: true
          description: When the account was locked
        password_changed_at:
          type: string
          format: date-time
          nullable: true
          description: When the password was last changed
          example: '2024-01-01T00:00:00Z'
    Session:
      type: object
      description: User session information
      properties:
        id:
          type: integer
          description: Session ID
          example: 456
        name:
          type: string
          description: Session name/description
          example: Mobile Access Token - 2024-01-15 10:30
        created_at:
          type: string
          format: date-time
          description: When the session was created
          example: '2024-01-15T10:30:00Z'
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: When the session was last used
          example: '2024-01-15T14:30:00Z'
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: When the session expires
          example: '2024-01-15T11:30:00Z'
        is_current:
          type: boolean
          description: Whether this is the current session
          example: true
        status:
          type: string
          enum:
          - active
          - idle
          - expired
          - revoked
          description: Current session status. A revoked session reads revoked, not
            expired (revoking also sets its expiry).
          example: active
    DetailedSession:
      allOf:
      - "$ref": "#/components/schemas/Session"
      - type: object
        properties:
          scopes:
            type: array
            items:
              type: string
            description: Permission scopes for this session
            example:
            - read:shifts
            - write:shifts
            - read:users
          business_id:
            type: integer
            description: Associated business ID
            example: 789
          user_agent:
            type: object
            description: User agent information
            properties:
              browser:
                type: string
                example: Chrome
              platform:
                type: string
                example: macOS
              raw:
                type: string
                example: Mobile Access Token - 2024-01-15 10:30
          location_info:
            type: object
            description: Location information (if available)
            properties:
              ip_address:
                type: string
                nullable: true
                description: IP address (not exposed for security)
              country:
                type: string
                nullable: true
                example: United States
              city:
                type: string
                nullable: true
                example: New York
              estimated:
                type: boolean
                description: Whether location is estimated
                example: true
          device_info:
            type: object
            description: Device information
            properties:
              type:
                type: string
                enum:
                - mobile
                - tablet
                - desktop
                example: mobile
              name:
                type: string
                example: Mobile Access Token - 2024-01-15 10:30
              trusted:
                type: boolean
                description: Whether the device is considered trusted
                example: true
          security_info:
            type: object
            description: Security-related session information
            properties:
              service_account:
                type: boolean
                description: Whether this is a service account token
                example: false
              has_secret:
                type: boolean
                description: Whether the token has an associated secret
                example: false
              token_type:
                type: string
                enum:
                - user_session
                - service_account
                description: Type of authentication token
                example: user_session
    PasswordlessToken:
      type: object
      description: Passwordless authentication token information
      properties:
        id:
          type: integer
          description: Token ID
          example: 123
        type:
          type: string
          enum:
          - magic_link
          - verification_code
          - sms_code
          description: Type of passwordless token
          example: magic_link
        created_at:
          type: string
          format: date-time
          description: When the token was created
          example: '2024-01-15T10:30:00Z'
        expires_at:
          type: string
          format: date-time
          description: When the token expires
          example: '2024-01-15T11:30:00Z'
        verified_at:
          type: string
          format: date-time
          nullable: true
          description: When the token was verified
          example: '2024-01-15T10:35:00Z'
        expired:
          type: boolean
          description: Whether the token has expired
          example: false
        verified:
          type: boolean
          description: Whether the token has been verified
          example: true
        revoked:
          type: boolean
          description: Whether the token has been revoked
          example: false
    PersonalApiToken:
      type: object
      description: Personal API token information
      properties:
        id:
          type: integer
          description: Token ID
          example: 456
        name:
          type: string
          description: Token name/description
          example: My Automation Token
        created_at:
          type: string
          format: date-time
          description: When the token was created
          example: '2024-01-15T10:30:00Z'
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: When the token was last used
          example: '2024-01-15T14:30:00Z'
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: When the token expires. Revoking a token also sets this to
            the time it was revoked.
          example: '2024-12-31T23:59:59Z'
        revoked_at:
          type: string
          format: date-time
          nullable: true
          description: When the token was revoked (by its owner, an admin, sign-out
            or rotation); null if it was never revoked
          example:
        expired:
          type: boolean
          description: Whether the token has expired. A revoked token reads true here
            too; use revoked to tell the two apart.
          example: false
        revoked:
          type: boolean
          description: Whether the token was revoked. Revocation is permanent; a revoked
            token never becomes active again.
          example: false
        active:
          type: boolean
          description: Whether the token can be used (neither expired nor revoked)
          example: true
        scopes:
          type: array
          items:
            type: string
          description: Token scopes/permissions
          example:
          - read:own_profile
          - read:own_shifts
    DetailedPersonalApiToken:
      allOf:
      - "$ref": "#/components/schemas/PersonalApiToken"
      - type: object
        properties:
          token:
            type: string
            description: The actual token value (only shown on creation/rotation)
            example: pat_1234567890abcdef
          business_id:
            type: integer
            description: Associated business ID
            example: 789
          service_account:
            type: boolean
            description: Whether this is a service account token
            example: false
          scope_count:
            type: integer
            description: Number of scopes assigned
            example: 4
          days_until_expiration:
            type: integer
            nullable: true
            description: Days until expiration (null if no expiration)
            example: 365
          usage_summary:
            type: object
            description: Usage summary information
            properties:
              last_used:
                type: string
                description: Human-readable last used time
                example: 2 hours ago
              created:
                type: string
                description: Human-readable creation time
                example: 3 days ago
    TokenUsage:
      type: object
      description: Token usage statistics and analytics
      properties:
        token_id:
          type: integer
          description: Token ID
          example: 456
        token_name:
          type: string
          description: Token name
          example: My Automation Token
        created_at:
          type: string
          format: date-time
          description: Token creation date
          example: '2024-01-15T10:30:00Z'
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: Last usage timestamp
          example: '2024-01-15T14:30:00Z'
        total_requests:
          type: integer
          description: Total number of API requests
          example: 1250
        requests_last_24h:
          type: integer
          description: Requests in the last 24 hours
          example: 45
        requests_last_7d:
          type: integer
          description: Requests in the last 7 days
          example: 320
        requests_last_30d:
          type: integer
          description: Requests in the last 30 days
          example: 1100
        most_used_endpoints:
          type: array
          items:
            type: object
            properties:
              endpoint:
                type: string
                example: "/api/v1/shifts"
              method:
                type: string
                example: GET
              count:
                type: integer
                example: 150
          description: Most frequently used endpoints
        recent_activity:
          type: array
          items:
            type: object
            properties:
              timestamp:
                type: string
                format: date-time
                example: '2024-01-15T14:30:00Z'
              endpoint:
                type: string
                example: "/api/v1/auth/profile"
              method:
                type: string
                example: GET
              status_code:
                type: integer
                example: 200
              ip_address:
                type: string
                example: 192.168.1.100
          description: Recent API activity
    UserSecuritySettings:
      type: object
      description: User security settings and status
      properties:
        two_factor_auth:
          type: object
          properties:
            enabled:
              type: boolean
              description: Whether two-factor authentication is enabled
              example: true
            backup_codes_count:
              type: integer
              description: Number of remaining backup codes
              example: 8
            last_used:
              type: string
              format: date-time
              nullable: true
              description: When 2FA was last used
              example: '2024-01-15T10:30:00Z'
            setup_required:
              type: boolean
              description: Whether 2FA setup is required
              example: false
        trusted_devices:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                description: Device ID
                example: device_123
              name:
                type: string
                description: Device name
                example: iPhone 15 Pro
              last_seen:
                type: string
                format: date-time
                description: When device was last seen
                example: '2024-01-15T14:22:00Z'
              location:
                type: string
                nullable: true
                description: Device location
                example: New York, NY
              ip_address:
                type: string
                description: Device IP address
                example: 192.168.1.100
              is_current:
                type: boolean
                description: Whether this is the current device
                example: true
        security_questions:
          type: object
          properties:
            configured:
              type: boolean
              description: Whether security questions are configured
              example: true
            last_updated:
              type: string
              format: date-time
              nullable: true
              description: When security questions were last updated
              example: '2024-01-10T09:15:00Z'
        login_history:
          type: array
          items:
            type: object
            properties:
              timestamp:
                type: string
                format: date-time
                description: Login attempt timestamp
                example: '2024-01-15T14:22:00Z'
              ip_address:
                type: string
                description: IP address of login attempt
                example: 192.168.1.100
              location:
                type: string
                nullable: true
                description: Geographic location of login
                example: New York, NY
              device:
                type: string
                description: Device information
                example: iPhone 15 Pro
              success:
                type: boolean
                description: Whether login was successful
                example: true
              failure_reason:
                type: string
                nullable: true
                description: Reason for login failure
                example:
        password_security:
          type: object
          properties:
            last_changed:
              type: string
              format: date-time
              nullable: true
              description: When password was last changed
              example: '2024-01-01T00:00:00Z'
            strength_score:
              type: integer
              description: Password strength score (0-100)
              example: 85
            expires_at:
              type: string
              format: date-time
              nullable: true
              description: When password expires
              example:
    AccountSettingsDashboard:
      type: object
      description: Account settings dashboard overview
      properties:
        profile_completion:
          type: object
          properties:
            percentage:
              type: integer
              description: Profile completion percentage
              example: 85
            missing_fields:
              type: array
              items:
                type: string
              description: List of missing profile fields
              example:
              - emergency_contact
              - skills
            next_steps:
              type: array
              items:
                type: object
                properties:
                  action:
                    type: string
                    description: Action identifier
                    example: complete_emergency_contact
                  title:
                    type: string
                    description: Action title
                    example: Add Emergency Contact
                  description:
                    type: string
                    description: Action description
                    example: Add an emergency contact for safety
                  priority:
                    type: string
                    enum:
                    - high
                    - medium
                    - low
                    description: Action priority
                    example: high
        security_status:
          type: object
          properties:
            score:
              type: integer
              description: Security score (0-100)
              example: 90
            two_factor_enabled:
              type: boolean
              description: Whether 2FA is enabled
              example: true
            password_strength:
              type: string
              enum:
              - weak
              - medium
              - strong
              description: Password strength assessment
              example: strong
            recommendations:
              type: array
              items:
                type: string
              description: Security recommendations
              example:
              - Review trusted devices
        notification_summary:
          type: object
          properties:
            total_categories:
              type: integer
              description: Total notification categories
              example: 12
            enabled_categories:
              type: integer
              description: Number of enabled categories
              example: 8
            unread_count:
              type: integer
              description: Number of unread notifications
              example: 3
            last_notification:
              type: string
              format: date-time
              nullable: true
              description: Timestamp of last notification
              example: '2024-01-15T10:30:00Z'
        recent_activity:
          type: array
          items:
            type: object
            properties:
              action:
                type: string
                description: Activity action
                example: profile_updated
              timestamp:
                type: string
                format: date-time
                description: Activity timestamp
                example: '2024-01-15T10:30:00Z'
              description:
                type: string
                description: Activity description
                example: Updated phone number
              ip_address:
                type: string
                nullable: true
                description: IP address of activity
                example: 192.168.1.100
        quick_actions:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                description: Action identifier
                example: upload_profile_photo
              title:
                type: string
                description: Action title
                example: Upload Profile Photo
              description:
                type: string
                description: Action description
                example: Personalize your account
              icon:
                type: string
                description: Action icon
                example: camera
              priority:
                type: string
                enum:
                - high
                - medium
                - low
                description: Action priority
                example: medium
    UserPrivacySettings:
      type: object
      description: User privacy and data management settings
      properties:
        data_sharing:
          type: object
          properties:
            analytics_enabled:
              type: boolean
              description: Whether analytics data sharing is enabled
              example: true
            performance_tracking:
              type: boolean
              description: Whether performance tracking is enabled
              example: true
            location_sharing:
              type: boolean
              description: Whether location sharing is enabled
              example: false
        visibility_settings:
          type: object
          properties:
            profile_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
              description: Profile visibility level
              example: team_only
            contact_info_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
              description: Contact information visibility
              example: managers_only
            schedule_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
              description: Schedule visibility level
              example: public
        data_retention:
          type: object
          properties:
            auto_delete_old_data:
              type: boolean
              description: Whether to automatically delete old data
              example: false
            retention_period_months:
              type: integer
              description: Data retention period in months
              example: 24
        export_options:
          type: object
          properties:
            last_export:
              type: string
              format: date-time
              nullable: true
              description: When data was last exported
              example: '2024-01-01T00:00:00Z'
            available_formats:
              type: array
              items:
                type: string
              description: Available export formats
              example:
              - json
              - csv
              - pdf
    UserPrivacySettingsUpdate:
      type: object
      description: Privacy settings update payload
      properties:
        data_sharing:
          type: object
          properties:
            analytics_enabled:
              type: boolean
            performance_tracking:
              type: boolean
            location_sharing:
              type: boolean
        visibility_settings:
          type: object
          properties:
            profile_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
            contact_info_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
            schedule_visibility:
              type: string
              enum:
              - public
              - team_only
              - managers_only
              - private
        data_retention:
          type: object
          properties:
            auto_delete_old_data:
              type: boolean
            retention_period_months:
              type: integer
    UserCommunicationSettings:
      type: object
      description: User communication preferences
      properties:
        preferred_language:
          type: string
          description: |
            The language content reaches the user in — their own setting, else
            their place's language, else the company default (Knowledge Layer
            P4; was the stored setting or "en"). Never null.
          example: en
        language_set:
          type: boolean
          description: |
            Whether the user chose a language themselves. `false` is the
            profile's "Not set" — `preferred_language` then follows their place
            or the company default.
          example: true
        timezone:
          type: string
          description: |
            The zone the user reads — their own choice, else their place's, else
            the company's — as stored: usually a Rails name, sometimes an IANA id.
          example: America/New_York
        time_zone_iana:
          type: string
          nullable: true
          description: |
            The same zone as a canonical IANA id, for Intl and moment-timezone,
            which know no Rails name. Null only when the zone can't be resolved.
            Read-only: a PATCH sets the zone through `timezone`.
          example: America/New_York
        timezone_set:
          type: boolean
          description: Whether the user chose a zone themselves (`false` — `timezone`
            follows their place or the company).
        communication_style:
          type: object
          properties:
            formality:
              type: string
              enum:
              - casual
              - professional
              - formal
              description: Communication formality level
              example: professional
            frequency:
              type: string
              enum:
              - minimal
              - normal
              - frequent
              description: Communication frequency preference
              example: normal
            channel_preference:
              type: string
              enum:
              - email
              - sms
              - in_app
              - phone
              description: Preferred communication channel
              example: email
        ai_assistant:
          type: object
          properties:
            enabled:
              type: boolean
              description: Whether AI assistant is enabled
              example: true
            personality:
              type: string
              enum:
              - helpful
              - concise
              - detailed
              - friendly
              description: AI assistant personality
              example: helpful
            proactivity_level:
              type: string
              enum:
              - low
              - medium
              - high
              description: AI assistant proactivity level
              example: medium
        meeting_preferences:
          type: object
          properties:
            preferred_times:
              type: array
              items:
                type: string
              description: Preferred meeting time slots
              example:
              - '09:00-11:00'
              - 14:00-16:00
            buffer_time_minutes:
              type: integer
              description: Buffer time between meetings in minutes
              example: 15
            virtual_meeting_preference:
              type: boolean
              description: Preference for virtual meetings
              example: true
    UserCommunicationSettingsUpdate:
      type: object
      description: Communication settings update payload
      properties:
        preferred_language:
          type: string
          nullable: true
          description: |
            The person's own language. `null` or blank clears it — the profile's
            "Not set": content then follows their place's language (Knowledge
            Layer P4). Sending back the value GET returned for someone who never
            chose one (`language_set: false`) changes nothing: it is their
            place's language, not a choice.
        timezone:
          type: string
        communication_style:
          type: object
          properties:
            formality:
              type: string
              enum:
              - casual
              - professional
              - formal
            frequency:
              type: string
              enum:
              - minimal
              - normal
              - frequent
            channel_preference:
              type: string
              enum:
              - email
              - sms
              - in_app
              - phone
        ai_assistant:
          type: object
          properties:
            enabled:
              type: boolean
            personality:
              type: string
              enum:
              - helpful
              - concise
              - detailed
              - friendly
            proactivity_level:
              type: string
              enum:
              - low
              - medium
              - high
        meeting_preferences:
          type: object
          properties:
            preferred_times:
              type: array
              items:
                type: string
            buffer_time_minutes:
              type: integer
            virtual_meeting_preference:
              type: boolean
    MobileAppMetadata:
      type: object
      properties:
        business:
          type: object
          properties:
            id:
              type: integer
              example: 123
            name:
              type: string
              example: Office Chat Solutions
            subdomain:
              type: string
              example: officechat
            logo_url:
              type: string
              nullable: true
              example: https://cdn.workforce.mangoapps.com/logos/officechat.png
            timezone:
              type: string
              example: America/New_York
            branding:
              "$ref": "#/components/schemas/Business/properties/branding"
        login_methods:
          type: object
          properties:
            email_password:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                forgot_password_enabled:
                  type: boolean
                  example: true
            sso_providers:
              type: array
              description: |
                Array of configured SSO providers for this business. Multiple providers of the same
                type (e.g., multiple Google OAuth2 configurations) are supported. Each provider has
                a unique ID that should be passed to the SSO initiate endpoint.
              items:
                type: object
                properties:
                  id:
                    type: string
                    description: Unique identifier for this SSO configuration (use
                      as provider_id)
                    example: '67'
                  provider_type:
                    type: string
                    enum:
                    - google_oauth2
                    - saml
                    - entra_id
                    - mangoapps
                    example: google_oauth2
                  provider_name:
                    type: string
                    description: Display name for this SSO configuration
                    example: Google Workspace
                  enabled:
                    type: boolean
                    description: Whether this SSO provider is currently enabled
                    example: true
                  logo_url:
                    type: string
                    description: URL to provider logo image
                    example: https://cdn.workforce.mangoapps.com/logos/google.png
                  description:
                    type: string
                    description: User-friendly description of this SSO provider
                    example: Sign in with your Google Workspace account
                  channel_support:
                    type: string
                    enum:
                    - web_only
                    - mobile_only
                    - both
                    description: Where this provider is enabled
                    example: both
                  mobile_supported:
                    type: boolean
                    description: Convenience flag indicating mobile support
                    example: true
                  web_supported:
                    type: boolean
                    description: Convenience flag indicating web support
                    example: true
              example:
              - id: '67'
                provider_type: google_oauth2
                provider_name: Google Workspace - Corporate
                enabled: true
                logo_url: https://cdn.workforce.mangoapps.com/logos/google.png
                description: Sign in with your corporate Google account
                channel_support: both
                mobile_supported: true
                web_supported: true
              - id: '134'
                provider_type: google_oauth2
                provider_name: Google Workspace - Partners
                enabled: true
                logo_url: https://cdn.workforce.mangoapps.com/logos/google.png
                description: Sign in with your partner Google account
                channel_support: mobile_only
                mobile_supported: true
                web_supported: false
              - id: '201'
                provider_type: saml
                provider_name: Okta SAML
                enabled: true
                logo_url: https://cdn.workforce.mangoapps.com/logos/okta.png
                description: Sign in with your organization's SAML identity provider
                channel_support: web_only
                mobile_supported: false
                web_supported: true
            two_factor:
              type: object
              properties:
                available:
                  type: boolean
                  example: true
                  description: Whether 2FA is available to users
                enforced:
                  type: boolean
                  example: false
                  description: Whether 2FA is required for all users
            biometric_login:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                methods:
                  type: array
                  items:
                    type: string
                    enum:
                    - face_id
                    - touch_id
                    - fingerprint
                  example:
                  - face_id
                  - touch_id
                  - fingerprint
            remember_device:
              type: object
              properties:
                enabled:
                  type: boolean
                  example: true
                duration_days:
                  type: integer
                  example: 30
                api_endpoints:
                  type: object
                  properties:
                    login:
                      type: string
                      example: "/api/v1/auth/login"
                    sso_initiate:
                      type: string
                      example: "/api/v1/auth/sso/initiate"
                    sso_exchange:
                      type: string
                      example: "/api/v1/auth/sso/exchange"
                    refresh_token:
                      type: string
                      example: "/api/v1/auth/refresh"
                    logout:
                      type: string
                      example: "/api/v1/auth/logout"
                    passwordless_request:
                      type: string
                      example: "/api/v1/auth/passwordless/request"
                    passwordless_verify:
                      type: string
                      example: "/api/v1/auth/passwordless/verify"
    InspectionStatus:
      type: string
      enum:
      - draft
      - scheduled
      - in_progress
      - in_review
      - completed
      - cancelled
    ComplianceStatus:
      type: string
      enum:
      - pending
      - passed
      - failed
      - na
      - skipped
    FailureSeverity:
      type: string
      enum:
      - minor
      - moderate
      - severe
      - critical
    InspectionItemType:
      type: string
      description: |
        Renderer hint for the field. Mirrors
        `InspectionTemplateItem#item_type`.
      enum:
      - yes_no
      - checkbox
      - rating
      - text
      - number
      - slider
      - datetime
      - photo
      - signature
      - select
      - multi_select
      - instruction
    InspectionSummary:
      type: object
      description: |
        Lightweight list row. Source: `InspectionSummarySerializer`.
        Returned by GET /inspections/inspections and the embedded
        `template`/`inspector`/`location` blocks.
      properties:
        id:
          type: integer
          example: 421
        title:
          type: string
          example: Loading dock walkthrough
          nullable: true
        status:
          "$ref": "#/components/schemas/InspectionStatus"
        display_status:
          type: string
          nullable: true
          description: |
            Human-readable badge label. Normally the lifecycle status
            ("Draft"/"Scheduled"/"In Progress"/"Passed"/"Failed"/"Cancelled"),
            but returns `"Overdue"` when `overdue` is true so the client can
            flag urgency. The raw lifecycle value is always on `status`.
          example: In Progress
        status_color:
          type: string
          description: |
            Bootstrap contextual color token for the status badge — matches the
            web UI's `bg-<status_color>` class so native clients render the same
            chip coloring. Returns `danger` when `overdue` is true.
          enum:
          - secondary
          - info
          - warning
          - success
          - danger
          - dark
          example: warning
        passed:
          type: boolean
          nullable: true
        score:
          type: number
          nullable: true
          example: 92.5
        template:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 12
            name:
              type: string
              example: General Safety Inspection
            category:
              type: string
              nullable: true
              example: safety
        inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 8
            name:
              type: string
              example: Jane Doe
            photo_url:
              type: string
              description: |
                Avatar URL for the inspector (web-parity). Falls back to a
                generated ui-avatars.com initial tile when no profile photo
                is attached, so this field is always present and renderable.
              example: "/rails/active_storage/representations/redirect/.../avatar.jpg"
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 3
            name:
              type: string
              example: Plant 3
        due_at:
          type: string
          format: date-time
          nullable: true
        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
        updated_at:
          type: string
          format: date-time
        progress_percentage:
          type: integer
          nullable: true
          example: 67
        overdue:
          type: boolean
          example: false
        review_reason:
          type: string
          nullable: true
          description: |
            Latest approve/reject reviewer comment on the inspection's approval
            workflow, so a list row can show why it was approved or returned for
            rework. Null when there is no decision yet (pending review, never
            reviewed, or no workflow attached). Source: `ApprovalAction#comments`.
            While the inspection is `in_progress`, the reason it was returned
            (`Inspection#last_return_feedback` — the newer of a reviewer's
            rejection and a Frontline campaign return), or null if it never was.
        returned_for_rework:
          type: boolean
          description: |
            Whether this in-progress inspection was sent back for rework, by a
            reviewer or a campaign reviewer. The detail payload carries the
            reason in `return_feedback`.
        current_level:
          type: integer
          nullable: true
          description: |
            1-based approval level the request currently sits at. Non-null only
            while the inspection is awaiting a decision (`in_review`); null
            otherwise.
          example: 1
        pending_with:
          type: string
          nullable: true
          description: |
            Display name of the level the inspection is awaiting a decision at
            (e.g. "Manager Review"), so the reviewer queue can render
            "Pending with <level>" without a detail fetch. Falls back to
            "Step N" when the step has no name of its own. Null when not awaiting a
            decision.
          example: Manager Review
      required:
      - id
      - status
      - created_at
      - updated_at
    InspectionLocationFilter:
      type: object
      description: |
        Team-feed location dropdown payload. Source:
        `Api::V1::Inspections::InspectionsController#team_location_filter`,
        backed by `Inspections::TeamScopeService#available_groups`. Surfaced as
        the top-level `location_filter` key on `GET /inspections/inspections`
        when `team=true`. `options` is the permission-scoped set the caller may
        filter by (their location assignments plus locations of inspections they
        can already see) — not merely the locations present on the current page.
      properties:
        axis:
          type: string
          description: |
            The tenant's `team_inspections_scope`. `options` is only a meaningful
            location list when this is `location`; for `department`/`none` the
            client should hide the location filter.
          enum:
          - location
          - department
          - none
          example: location
        selected_id:
          type: integer
          nullable: true
          description: The currently-applied `location_id`, echoed back. Null for
            the default "all my locations" view.
          example: 3
        options:
          type: array
          description: Locations the caller may filter by. Render after an "All my
            locations" sentinel.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 3
              name:
                type: string
                example: Plant 3
      required:
      - axis
      - options
    InspectionItemMediaItem:
      type: object
      description: |
        Polymorphic `MediaItem` row attached to an inspection item — photo
        OR video. `media_kind` distinguishes the two. Source:
        `InspectionItemSerializer#media_item_payload`. Replaces the legacy
        `InspectionItemPhoto` / `InspectionItemVideo` blob shapes (which
        flattened Active Storage blobs); evidence now flows through the
        `MediaItem` model and is uploaded via the direct-upload + attach
        flow described in `POST /api/v1/inspections/uploads/direct`.
      properties:
        id:
          type: integer
        media_kind:
          type: string
          enum:
          - photo
          - video
          example: photo
        content_type:
          type: string
          example: image/jpeg
        byte_size:
          type: integer
          example: 2097152
        original_filename:
          type: string
          nullable: true
          example: photo-1716499200.jpg
        url:
          type: string
          example: "/rails/active_storage/blobs/redirect/abc/photo.jpg"
        thumb_url:
          type: string
          nullable: true
          example: "/rails/active_storage/representations/.../photo.jpg"
        has_annotations:
          type: boolean
          example: false
        captured_at:
          type: string
          format: date-time
          nullable: true
      required:
      - id
      - media_kind
      - content_type
      - byte_size
      - url
    InspectionItemTemplateSnapshot:
      type: object
      description: |
        Subset of the parent template-item fields the inspection-item
        serializer inlines so the renderer doesn't need a second fetch.
      properties:
        id:
          type: integer
        item_type:
          "$ref": "#/components/schemas/InspectionItemType"
        description:
          type: string
          nullable: true
        instructions:
          type: string
          nullable: true
          description: The template author's instructions to the inspector, drawn
            under the description.
        requires_photo:
          type: boolean
        requires_note:
          type: boolean
          example: false
          description: The template asks a note on this item; completing refuses an
            answered item without one (422 `note_required`).
        invert_yes_no:
          type: boolean
          example: false
          description: |
            When true the yes/no question is inverted — "No" is the compliant
            answer and "Yes" fails. Renderers flip the answer chips and the
            scoring engine flips pass/fail.
        min_value:
          type: number
          nullable: true
        max_value:
          type: number
          nullable: true
        options:
          type: object
          nullable: true
          additionalProperties: true
        form_conditions:
          "$ref": "#/components/schemas/InspectionFormConditions"
        standard_text:
          type: string
          nullable: true
          description: The item's written photo standard — what a photo of it should
            show.
        reference_image_url:
          type: string
          nullable: true
          description: |
            The photo standard's reference picture, as a signed link valid for
            an hour: show it, never store it — fetch the inspection again for a
            fresh one. Null when the item has none (or its reference was archived).
    InspectionItem:
      type: object
      description: |
        One row from the inspection's `items` array. Source:
        `InspectionItemSerializer`. The `template_item` block carries the
        item-type metadata; `compliance_status` carries the user's answer.
      properties:
        id:
          type: integer
        inspection_id:
          type: integer
        template_item_id:
          type: integer
          nullable: true
        name:
          type: string
          example: Tires properly inflated
        section_name:
          type: string
          nullable: true
          example: Pre-Start Safety
        position:
          type: integer
          example: 0
        weight:
          type: number
          example: 1.0
        critical:
          type: boolean
          example: false
        compliance_status:
          "$ref": "#/components/schemas/ComplianceStatus"
        response_value:
          type: string
          nullable: true
          example: 'Yes'
        display_answer:
          type: string
          nullable: true
          example: 45 °F
          description: The answer as the web reports print it — option labels, a number
            with its unit, a formatted time. Null for yes/no, checkbox, photo, signature
            and instruction items, and when unanswered.
        notes:
          type: string
          nullable: true
        failure_severity:
          "$ref": "#/components/schemas/FailureSeverity"
          nullable: true
        score:
          type: number
          nullable: true
          example: 0.95
        responded_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
        server_version:
          type: string
          description: updated_at at microsecond precision (opaque). Echo as `base_version`
            on sync.
        template_item:
          "$ref": "#/components/schemas/InspectionItemTemplateSnapshot"
          nullable: true
        photo_required:
          type: boolean
          example: true
          description: The item must carry a photo before the inspection can be submitted
            — its template sets `requires_photo`, or it is a required photo-answer
            item. The rule the submit enforces; false once the item is marked N/A
            or skipped.
        media_items:
          type: array
          description: |
            Photos + videos attached to this item, in `MediaItem#ordered`
            sequence (the same order the web inspector renders). Photos
            and videos are intermixed; filter client-side by `media_kind`
            if a kind-specific view is needed.
          items:
            "$ref": "#/components/schemas/InspectionItemMediaItem"
        media_count:
          type: integer
          example: 2
          description: Total media (photos + videos).
        photo_count:
          type: integer
          example: 2
        ai_photo_check:
          type: object
          nullable: true
          description: |
            The AI photo check's verdict on the item's photos, against the
            checklist item's photo standard (a reference picture and/or a written
            standard): the worst verdict among the photos still attached. A
            suggestion the evaluator confirms; null when the checklist item has no
            standard or no attached photo has been checked. A verdict given while
            the check was on stays after it is turned off.
            A check that could not run (an unreadable photo, no answer from the
            model, an AI usage limit, an error) reads verdict `unchecked` with
            its `reason`; attaching the photo again checks it again.
          properties:
            verdict:
              type: string
              enum:
              - matches
              - deviation
              - unclear
              - unchecked
            summary:
              type: string
            reason:
              type: string
              enum:
              - unreadable
              - no_response
              - ai_blocked
              - error
              - no_standard
              description: 'Only on an `unchecked` verdict: why the check did not
                run.'
            media_item_id:
              type: integer
            checked_at:
              type: string
              format: date-time
        video_count:
          type: integer
          example: 0
      required:
      - id
      - inspection_id
      - name
      - position
      - compliance_status
      - updated_at
    InspectionGps:
      type: object
      description: GPS capture stamped on the inspection at submit time.
      properties:
        latitude:
          type: number
          nullable: true
          example: 40.7128
        longitude:
          type: number
          nullable: true
          example: -74.006
        accuracy:
          type: number
          nullable: true
          example: 5.2
        address:
          type: string
          nullable: true
          example: New York, NY
        captured_at:
          type: string
          format: date-time
          nullable: true
    InspectionApproval:
      type: object
      description: |
        Approval-workflow state when the inspection is `in_review`. The whole
        block is omitted for a non-reviewer's pending request; `can_review` is
        true only for an eligible reviewer at the current level.
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - escalated
          - auto_approved
          - cancelled
        current_level:
          type: integer
          example: 1
        requested_at:
          type: string
          format: date-time
          nullable: true
        workflow_id:
          type: integer
          nullable: true
        can_review:
          type: boolean
          description: |
            True when the current caller may approve/reject at the current
            level — the person's answer. Gate the Approve affordance on
            `can_approve` and Reject on `can_reject` (server-authoritative), not
            on client-side role inference.
          example: true
        can_approve:
          type: boolean
          description: |
            `can_review` AND the caller's token holds `write:inspections`,
            which the review_approve endpoint asks. A read-only token may read
            the review but its approve answers 403. A session always passes the
            scope half.
          example: true
        can_reject:
          type: boolean
          description: |
            `can_review` AND the caller's token holds `destructive:inspections`,
            which the review_reject endpoint asks (a rejection is a final act).
            A write-only token may approve but its reject answers 403. A
            session always passes the scope half.
          example: true
        last_action:
          type: object
          nullable: true
          description: Most recent decision (approve/reject) on this request; null
            before any decision.
          properties:
            action:
              type: string
              enum:
              - approved
              - rejected
              example: rejected
            reason:
              type: string
              nullable: true
              description: Reviewer comment (required on reject).
            acted_at:
              type: string
              format: date-time
              nullable: true
            acted_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
    InspectionUserRef:
      type: object
      nullable: true
      description: Lightweight user reference. `name` is the user's display name;
        null when no actor is recorded.
      properties:
        id:
          type: integer
          nullable: true
        name:
          type: string
          nullable: true
    InspectionReviewTimeline:
      type: object
      description: |
        Read-only review timeline — the same submitted → decisions → awaiting →
        upcoming → terminal sequence the desktop "Review Timeline" card renders,
        plus prior rework rounds. Unlike `approval` (reviewer-gated for action
        affordances), this block is visible to everyone who can view the
        inspection. The whole object is null when the inspection never entered
        review and carries no workflow_history audit trail.

        Two modes:
          - Approval-backed: `workflow` is present and `nodes` use the typed
            entries below (submitted/decision/awaiting/upcoming/terminal).
          - Legacy / no-workflow completion: `workflow`, `status`, and
            `current_level` are null and `nodes` are `event` entries built from
            the inspection's workflow_history audit trail.
      properties:
        status:
          type: string
          nullable: true
          enum:
          - pending
          - approved
          - rejected
          - escalated
          - auto_approved
          - cancelled
          description: Latest ApprovalRequest status; null in the workflow_history
            fallback.
        current_level:
          type: integer
          nullable: true
        workflow:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            max_levels:
              type: integer
              example: 2
        nodes:
          type: array
          description: Ordered timeline entries. Render by `type`.
          items:
            type: object
            description: |
              One of six shapes, discriminated by `type`:
                - submitted: { type, label, by, at }
                - decision:  { type, status, label, level_number, level_name, by, at, response_time_hours, comments }
                - awaiting:  { type, label, level_number, level_name, step, total_steps, eligible_reviewers[] }
                - upcoming:  { type, label, level_number, level_name }
                - terminal:  { type, status, label, completed_at }
                - event:     { type, action, label, by, at, comments }  # workflow_history fallback
            properties:
              type:
                type: string
                enum:
                - submitted
                - decision
                - awaiting
                - upcoming
                - terminal
                - event
              label:
                type: string
              status:
                type: string
                nullable: true
                description: 'decision: approved|rejected|escalated|auto_approved
                  · terminal: approved|rejected|escalated|cancelled'
              action:
                type: string
                nullable: true
                description: Raw action for `event` nodes (e.g. submitted, completed,
                  returned).
              level_number:
                type: integer
                nullable: true
              level_name:
                type: string
                nullable: true
              step:
                type: integer
                nullable: true
              total_steps:
                type: integer
                nullable: true
              response_time_hours:
                type: number
                nullable: true
                example: 14.73
              comments:
                type: string
                nullable: true
                description: Null when blank — never an empty string.
              completed_at:
                type: string
                format: date-time
                nullable: true
              at:
                type: string
                format: date-time
                nullable: true
              by:
                "$ref": "#/components/schemas/InspectionUserRef"
              eligible_reviewers:
                type: array
                description: Present on `awaiting` nodes — all reviewers eligible
                  at the current level.
                items:
                  "$ref": "#/components/schemas/InspectionUserRef"
        previous_rounds:
          type: array
          description: Earlier rework loops, most-recent first. Empty in the fallback.
          items:
            type: object
            properties:
              round:
                type: integer
                description: Chronological index (1 = oldest).
              status:
                type: string
                enum:
                - pending
                - approved
                - rejected
                - escalated
                - auto_approved
                - cancelled
              submitted_by:
                "$ref": "#/components/schemas/InspectionUserRef"
              submitted_at:
                type: string
                format: date-time
                nullable: true
              actions:
                type: array
                items:
                  type: object
                  properties:
                    action:
                      type: string
                      example: rejected
                    by:
                      "$ref": "#/components/schemas/InspectionUserRef"
                    level_number:
                      type: integer
                      nullable: true
                    level_name:
                      type: string
                      nullable: true
                    comments:
                      type: string
                      nullable: true
                    acted_at:
                      type: string
                      format: date-time
                      nullable: true
    InspectionAsset:
      type: object
      description: AssetPro asset (equipment) linked to the inspection.
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
          example: Toyota 8FGCU25 — FL-04
        condition:
          type: string
          nullable: true
          enum:
          - excellent
          - good
          - fair
          - poor
          - damaged
          - unknown
    InspectionMetadata:
      type: object
      description: |
        Sanitized client-side metadata (the full `metadata` JSONB is not
        surfaced to mobile to keep the workflow_history audit log private).
      properties:
        device_info:
          type: string
          nullable: true
        app_version:
          type: string
          nullable: true
        offline_created:
          type: boolean
          nullable: true
        synced_at:
          type: string
          format: date-time
          nullable: true
        weather_conditions:
          type: string
          nullable: true
    InspectionCorrectiveActionUser:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          example: Jane Doe
    InspectionCorrectiveAction:
      type: object
      description: |
        Auto-generated follow-up task created from a failed item. Source:
        `CorrectiveActionSerializer`. Links back to the inspection item
        that produced it; can also be linked to the Tasks app via `task_id`.
      properties:
        id:
          type: integer
        inspection_id:
          type: integer
        inspection_item_id:
          type: integer
          nullable: true
        description:
          type: string
          example: Replace damaged eyewash bottle.
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
        status_label:
          type: string
          enum:
          - Not started
          - In progress
          - Completed
          - Cancelled
          description: |
            The word every screen shows for `status` (Capa::Action#status_label):
            `pending` is "Not started", never "Pending". An action reads
            "In progress" only once someone starts the work — assigning it
            never moves the status. Show this; keep `status` for logic.
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        due_date:
          type: string
          format: date
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        resolution_notes:
          type: string
          nullable: true
        assigned_to:
          "$ref": "#/components/schemas/InspectionCorrectiveActionUser"
          nullable: true
        auto_assignment_rule:
          type: string
          nullable: true
          enum:
          - location_manager
          - failure_group
          - app_admin
          - admin
          -
          description: |
            Which tier of the Inspections owner rule chose `assigned_to` when
            the app assigned the action on its own (a failed item at
            completion, a failure-prompt action adopted at completion, a rack
            escalation): a manager of the inspection's location, a member of
            the failure alert group, an Inspections app admin, or an
            organization admin — the first of these who can open the
            inspection. `null` when a person chose the owner, or the action
            has been reassigned since.
        auto_assignment_reason:
          type: string
          nullable: true
          example: Assigned automatically as a manager at Corporate Office.
          description: |
            The sentence every screen shows beside the assignee for an
            action the app assigned (Capa::Action#auto_assignment_reason).
            `null` exactly when `auto_assignment_rule` is.
        created_by:
          "$ref": "#/components/schemas/InspectionCorrectiveActionUser"
          nullable: true
        inspection:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            title:
              type: string
              nullable: true
            url:
              type: string
              description: |
                Mobile-web deep link (`/m/apps/inspections/<id>`) — native
                clients open the inspection read-only inside the in-app
                WebView, matching the inspections list `url`.
        inspection_item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            section_name:
              type: string
              nullable: true
        overdue:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    InspectionCorrectiveActionHandoff:
      type: object
      description: |
        Present only when the completion this response reports created
        corrective actions or gave failure-prompt actions an owner: who they
        went to, read from the stored rows. Each was assigned to the first
        person who can open the inspection — a manager of its location, then a
        member of the failure alert group, then an Inspections app admin, then
        an organization admin (see `auto_assignment_rule` on each action).
      properties:
        count:
          type: integer
          description: Actions created or handed an owner by this completion.
        assigned:
          type: array
          items:
            type: object
            properties:
              user_id:
                type: integer
              name:
                type: string
              count:
                type: integer
        unassigned_count:
          type: integer
          description: Actions nobody could be assigned automatically.
        message:
          type: string
          example: 2 corrective actions created and assigned to Rosa Delgado.
          description: The sentence the web Complete notice shows, in the caller's
            language.
    InspectionDetail:
      allOf:
      - "$ref": "#/components/schemas/InspectionSummary"
      - type: object
        description: |
          Full inspection payload used by show / create / complete / cancel.
          Adds notes, GPS, items, corrective actions, approval state, and
          sanitized metadata. Source: `InspectionDetailSerializer`.
        properties:
          notes:
            type: string
            nullable: true
          summary:
            type: string
            nullable: true
          ai_summary:
            type: string
            nullable: true
            description: |
              AI-generated executive summary stamped on submit. Null until the
              background summarizer finishes, or when AI summarization is
              disabled for the tenant.
          inspector_signature:
            type: string
            nullable: true
            description: Stored URL or data-uri for the captured signature.
          source_type:
            type: string
            nullable: true
            enum:
            - manual
            - scheduled
            - asset_requirement
          duration_seconds:
            type: integer
            nullable: true
            description: Seconds from start to hand-in as stored at hand-in; nil on
              rows from before it was stored. Prefer start_to_finish_seconds.
          start_to_finish_seconds:
            type: integer
            nullable: true
            description: Start to finish — elapsed seconds from start to hand-in (submission
              for review, else completion), the figure every screen shows. Not hands-on
              time.
          gps:
            "$ref": "#/components/schemas/InspectionGps"
          score_breakdown:
            type: object
            nullable: true
            additionalProperties: true
          sections:
            type: array
            items:
              type: string
          items:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionItem"
          corrective_actions:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionCorrectiveAction"
          approval:
            "$ref": "#/components/schemas/InspectionApproval"
            nullable: true
          review_timeline:
            "$ref": "#/components/schemas/InspectionReviewTimeline"
            nullable: true
          return_feedback:
            type: object
            nullable: true
            description: |
              Why this in-progress inspection was sent back for rework — the
              newer of a reviewer's rejection and a Frontline campaign return.
              Null unless `returned_for_rework`.
            properties:
              comment:
                type: string
                nullable: true
              by:
                type: string
                nullable: true
                description: The reviewer's name, when known.
              at:
                type: string
                format: date-time
                nullable: true
          asset:
            "$ref": "#/components/schemas/InspectionAsset"
            nullable: true
          previous_visit:
            type: object
            description: |
              The same location's previous completed inspection on this
              checklist and how items changed since it. `available` is false
              for a first visit or an inspection with nothing to compare by (no
              location, asset or facility layout). Visits are ordered by when
              they were handed in. On an open inspection the only item change
              is `failed_last_visit`; on a completed one, `failed_again`,
              `newly_failed` or `fixed`.
            properties:
              available:
                type: boolean
              previous_inspection_id:
                type: integer
              previous_completed_at:
                type: string
                format: date-time
                nullable: true
                description: When the previous visit was completed — its approval
                  time on a review-gated checklist.
              previous_visited_at:
                type: string
                format: date-time
                nullable: true
                description: When the previous visit happened (handed in by its inspector).
                  Date a visit by this.
              previous_score:
                type: number
                nullable: true
              score_change:
                type: number
                nullable: true
              counts:
                type: object
                properties:
                  failed_again:
                    type: integer
                  newly_failed:
                    type: integer
                  fixed:
                    type: integer
              items:
                type: array
                items:
                  type: object
                  properties:
                    inspection_item_id:
                      type: integer
                    name:
                      type: string
                    change:
                      type: string
                      enum:
                      - failed_again
                      - newly_failed
                      - fixed
                      - failed_last_visit
          metadata:
            "$ref": "#/components/schemas/InspectionMetadata"
          can_delete:
            type: boolean
            description: |
              True when the current caller may delete this inspection. Reflects
              BOTH gates the DELETE endpoint enforces: the per-business
              `allow_delete_inspections` setting (off by default) AND modify
              authority (the assigned inspector or a manager/inspections-admin).
              False when deletion is disabled business-wide, so the native
              client should hide the Delete affordance. Emitted on the show
              payload only. Source: `InspectionsController#can_delete_inspection?`.
          can_take_over:
            type: boolean
            description: |
              True when the current caller may take over this in-progress
              inspection (or pick up a scheduled one, when the tenant allows
              pickup) — i.e. a manager/admin or a peer assigned to the
              inspection's location who is not already the inspector. Drives
              the native "Continue"/"Pick up this inspection" affordance.
              Source: `Inspection#takeable_by?`.
          can_review:
            type: boolean
            description: |
              True when the current caller may approve/reject this inspection
              right now — i.e. it is in review and the caller is an eligible
              reviewer at the current approval level (submitter excluded by
              separation of duties). Drives the native "Review" affordance.
              Always present on the detail payload (false when nothing is in
              review or the caller isn't eligible) — unlike the reviewer-gated
              `approval.can_review`, which is omitted with the whole `approval`
              block for a non-reviewer's pending request. Source:
              `InspectionDetailSerializer#can_review?` →
              `ApprovalLevel#can_approve?`.
          can_reject:
            type: boolean
            description: |
              `can_review` AND the caller's token holds
              `destructive:inspections`, which the review_reject endpoint asks.
              Draw Reject off this flag and Approve off `can_approve`.
          can_approve:
            type: boolean
            description: |
              `can_review` AND the caller's token holds `write:inspections`,
              which the review_approve endpoint asks — a read-only token may
              read the review but its approve answers 403.
          last_activity_at:
            type: string
            format: date-time
            nullable: true
            description: |
              Most-recent activity across the inspection record, its items
              (status/response/notes edits), and their media. The "Last
              updated" recency signal for the takeover decision — reliable
              where `updated_at` alone is not, since item edits don't touch
              the parent inspection. Source: `Inspection#last_activity_at`.
          server_version:
            type: string
            description: |
              The inspection's updated_at at microsecond precision (opaque).
              Echo as `inspection.base_version` on sync; guards notes /
              summary / gps_* only.
          template:
            type: object
            nullable: true
            description: |
              On the detail payload the nested template block is enriched
              (beyond the summary's id/name/category) with `require_signature`
              and the per-template failure follow-up prompt config, so the
              conduct/complete screen can decide whether to prompt for a
              signature and drive the failure-capture modal without a second
              call to the template detail/bundle endpoint.
              Source: `InspectionDetailSerializer`.
            properties:
              id:
                type: integer
                example: 12
              name:
                type: string
                example: General Safety Inspection
              category:
                type: string
                nullable: true
                example: safety
              require_signature:
                type: boolean
                description: |
                  Whether the inspector must capture a digital signature
                  before completing. When false the native client skips the
                  signature step. Same canonical
                  `InspectionTemplate#require_signature?` predicate the
                  template summary/detail payloads expose.
              failure_prompt_settings:
                type: object
                nullable: true
                description: |
                  Per-template controls for the failure-capture modal:
                  `prompt_enabled` (boolean master toggle) plus `prompt_photo`,
                  `prompt_severity`, `prompt_comment`, `prompt_action_item`
                  (each "off" | "optional" | "required"). Null when the
                  inspection has no template; an empty object `{}` when the
                  template exists but was never configured — clients resolve a
                  missing master → false and any missing field → "off".
                additionalProperties: true
    InspectionTemplateSummary:
      type: object
      description: |
        Compact template row for the picker. Source:
        `TemplateSummarySerializer`.
      properties:
        id:
          type: integer
          example: 12
        name:
          type: string
          example: Forklift Daily Pre-Use
        description:
          type: string
          nullable: true
          example: Daily pre-shift safety check…
        category:
          type: string
          nullable: true
          example: safety
        inspection_type:
          type: string
          nullable: true
          example: equipment
        pass_threshold:
          type: number
          nullable: true
          example: 80.0
        estimated_duration_minutes:
          type: integer
          nullable: true
          description: |
            Approximate time to complete the inspection (minutes). Authored on
            the template; surfaced on the list row so the picker can show
            "~15 min" without a follow-up detail call.
          example: 15
        item_count:
          type: integer
          description: |
            Items an inspection of this template asks — the active ones. An
            item switched off in the builder is not counted (the detail
            payload's `items` leaves it out too).
          example: 13
        inspections_count:
          type: integer
          description: |
            The template's counter of inspections in every status. On a shared
            system template (`include_system=true`) it counts every
            organization's. For this organization's completed inspections read
            `completed_inspections_count`.
          example: 142
        completed_inspections_count:
          type: integer
          description: |
            This organization's completed inspections of the template. Present
            on the template list and the template detail; absent where a
            payload does not resolve it (the offline bundle manifest).
          example: 97
        require_signature:
          type: boolean
        requires_training:
          type: boolean
        has_approval_workflow:
          type: boolean
        active:
          type: boolean
        is_system_template:
          type: boolean
        updated_at:
          type: string
          format: date-time
      required:
      - id
      - name
      - active
      - updated_at
    InspectionTemplateItem:
      type: object
      description: |
        One field in a template definition. Source: the inline
        `template_item_payload` in `TemplateDetailSerializer`.
      properties:
        id:
          type: integer
        position:
          type: integer
          example: 0
        section_name:
          type: string
          nullable: true
          example: Pre-Start Safety Checks
        name:
          type: string
          example: Tires properly inflated
        description:
          type: string
          nullable: true
        instructions:
          type: string
          nullable: true
          description: The template author's instructions to the inspector, drawn
            under the description.
        item_type:
          "$ref": "#/components/schemas/InspectionItemType"
        weight:
          type: number
          example: 1.0
        critical:
          type: boolean
          example: false
        requires_photo:
          type: boolean
          example: false
        requires_note:
          type: boolean
          example: false
          description: The template asks a note on this item; completing refuses an
            answered item without one (422 `note_required`).
        auto_fail_on_no:
          type: boolean
          example: false
        invert_yes_no:
          type: boolean
          example: false
          description: |
            When true the yes/no question is inverted — "No" is the compliant
            answer and "Yes" fails. Renderers flip the answer chips and the
            scoring engine flips pass/fail.
        min_value:
          type: number
          nullable: true
        max_value:
          type: number
          nullable: true
        options:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            Item-type-specific configuration. For `select`/`multi_select`
            this is `{ choices: [{ value, label }] }`. For `number`/`rating`
            it may include `unit`, `step`, etc. For `slider` it pairs with
            `min_value`/`max_value`.
        form_conditions:
          "$ref": "#/components/schemas/InspectionFormConditions"
      required:
      - id
      - position
      - name
      - item_type
    InspectionFormConditions:
      type: object
      nullable: true
      additionalProperties: true
      description: |
        FormKit conditional-logic contract for a branching template item, or
        null for unconditional items. Shape:

            {
              "show_when":    [ { "field": "item_<parent_template_item_id>",
                                  "operator": "equals" | "in_list",
                                  "value": "yes" | ["opt_a", "opt_b"] } ],
              "hide_when":    [],
              "require_when": [ ...same rule shape... ]
            }

        Evaluation semantics (mirror the server's FormKit::ConditionEvaluator
        and the web client's form_conditions.js — clients must match):
        - `field` refers to another item in the same template, keyed
          `item_<template_item_id>`. Compare against the item's ANSWER value —
          for `yes_no`/`checkbox` items that is the raw `"yes"`/`"no"`/`"na"`
          string (NOT compliance_status; inverted items still answer
          "yes"/"no"), for every other type the literal `response_value`;
          unanswered compares as `""`.
        - Multiple rules in one array are AND-ed.
        - Visibility: non-empty `show_when` decides; else non-empty
          `hide_when` inverts; else visible. A hidden item is never required,
          is excluded from scoring/progress, and does not block completion.
          Hidden items' answers are treated as blank when evaluating OTHER
          items' rules (chained conditions).
        - Required: non-empty `require_when` decides (only while visible);
          otherwise the item is required by default.
        - The server currently emits only `equals` and `in_list`, but the
          evaluator accepts the full operator set: equals, not_equals,
          contains, not_contains, greater_than, less_than,
          greater_than_or_equal, less_than_or_equal, is_empty, is_not_empty,
          in_list, not_in_list, starts_with, ends_with, matches_pattern.
          Numeric operators coerce with Ruby `to_f` semantics
          (nil/blank/non-numeric → 0). Unknown operators evaluate false.
        - String comparisons are CASE-INSENSITIVE: `equals`/`not_equals`/
          `in_list`/`not_in_list` also trim surrounding whitespace;
          `contains`/`starts_with`/`ends_with` fold case only.
          `matches_pattern` stays case-sensitive. Clients must implement the
          same folding.
        - A `multi_select` parent's answer evaluates as an ARRAY of the
          selected values: `equals`/`contains`/`in_list` match when ANY
          selected value matches (server and clients agree on this).
    InspectionTemplateDetail:
      allOf:
      - "$ref": "#/components/schemas/InspectionTemplateSummary"
      - type: object
        description: |
          Full template payload used by GET /templates/:id. Adds the
          items array, sections order, estimated duration, and the
          template-level failure-prompt settings the renderer uses to
          drive the failure modal.
        properties:
          description:
            type: string
            nullable: true
          estimated_duration_minutes:
            type: integer
            nullable: true
          sections_order:
            type: array
            items:
              type: string
          failure_prompt_settings:
            type: object
            nullable: true
            description: |
              Per-template controls for the failure-capture modal.
              Keys map to `prompt_enabled`, `prompt_photo`, `prompt_severity`,
              `prompt_comment`, `prompt_action_item`.
            additionalProperties: true
          max_photos_per_item:
            type: integer
            nullable: true
            description: |
              App-level cap (Evidence & Input setting) on photos per
              inspection item. Null when unset; clients should apply their
              own default. Same value for every item in the template.
          items:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionTemplateItem"
    InspectionCreateItem:
      type: object
      description: |
        Item update payload submitted as part of `POST /inspections`. The
        server matches it to an existing `inspection_item` by `id`
        (preferred — used in the sync flow) or by `template_item_id`
        (used in the create-from-template flow, where the inspection_items
        are auto-created by the model's `after_create` callback).
      properties:
        id:
          type: integer
          nullable: true
          description: Inspection item id (sync flow only).
        template_item_id:
          type: integer
          nullable: true
          description: Template item id (create flow).
        compliance_status:
          "$ref": "#/components/schemas/ComplianceStatus"
        response_value:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        failure_severity:
          "$ref": "#/components/schemas/FailureSeverity"
          nullable: true
    InspectionCreateMediaUpload:
      type: object
      description: |
        Refers to an Active Storage blob the client already PUT to S3 via
        `/inspections/direct_uploads`. Attaches it to a specific
        inspection item once the inspection is created. Used when the
        client wants to upload media before knowing the inspection id —
        in the normal flow callers use the per-item photos endpoint instead.
      properties:
        item_id:
          type: integer
        blob_signed_id:
          type: string
          example: eyJfcmFpbHMi...
        kind:
          type: string
          enum:
          - photo
          - video
          default: photo
    InspectionCreateRequest:
      type: object
      description: Body for `POST /inspections/inspections`.
      properties:
        inspection_template_id:
          type: integer
          example: 12
        title:
          type: string
          nullable: true
          example: Forklift FL-04 daily check
        location_id:
          type: integer
          nullable: true
        asset_pro_asset_id:
          type: integer
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        notes:
          type: string
          nullable: true
        gps_latitude:
          type: number
          nullable: true
        gps_longitude:
          type: number
          nullable: true
        gps_accuracy:
          type: number
          nullable: true
        gps_address:
          type: string
          nullable: true
        gps_captured_at:
          type: string
          format: date-time
          nullable: true
        items:
          type: array
          description: Optional batch of item updates (offline submissions).
          items:
            "$ref": "#/components/schemas/InspectionCreateItem"
        media_uploads:
          type: array
          description: Optional blobs to attach to specific inspection items.
          items:
            "$ref": "#/components/schemas/InspectionCreateMediaUpload"
        complete:
          type: boolean
          default: false
          description: When true, the server calls `submit!` after applying item updates.
        complete_notes:
          type: string
          nullable: true
          description: Final notes to record on the submission (used when `complete=true`).
      required:
      - inspection_template_id
    InspectionUpdateRequest:
      type: object
      description: |
        Body for `PATCH /inspections/inspections/:id`. Mirrors the desktop Edit
        Inspection form's field set exactly. Accepts the fields at the top
        level or nested under an `inspection: { ... }` key so callers can use
        either shape. `inspection_template_id` is silently rejected
        (422 `template_locked`) when the inspection is `in_progress` or
        `completed` — matches the web form, where the template select is
        disabled in those states.
      properties:
        title:
          type: string
          nullable: true
        inspection_template_id:
          type: integer
          nullable: true
          description: Locked when the inspection is in_progress or completed.
        location_id:
          type: integer
          nullable: true
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        notes:
          type: string
          nullable: true
    InspectionCorrectiveActionCreateItem:
      type: object
      description: |
        Follow-up action carried inline on the `sync` / `complete` body.
        Each entry is either an UPDATE (when `id` is supplied — must reference
        a corrective action already on this inspection) or a CREATE (any other
        case). Validation mirrors `POST /inspections/inspections/:id/corrective_actions`:
        cross-tenant `assigned_to_id`, off-inspection `inspection_item_id`,
        and an unknown priority/status all 422 the whole sync / complete
        request (atomic — no partial writes). A status the dedicated endpoints
        will not write is different: the entry is skipped whole, the rest of
        the request saves, and the response lists it in
        `skipped_corrective_actions` (#6883).
      properties:
        id:
          type: integer
          nullable: true
          description: Existing inspection_corrective_action id (update mode).
        description:
          type: string
          description: Required on create. Max 2000 chars.
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          default: medium
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          default: pending
          description: "`completed` stamps the completion date. `cancelled` is never
            written, and a completed or cancelled action's status does not change
            (`completed` sent again for a completed action is a no-op): such an entry
            is skipped and listed in `skipped_corrective_actions`."
        due_date:
          type: string
          format: date
          nullable: true
        assigned_to_id:
          type: integer
          nullable: true
          description: Must be a member of the calling business.
        inspection_item_id:
          type: integer
          nullable: true
          description: Must belong to the inspection being synced/completed.
        resolution_notes:
          type: string
          nullable: true
    InspectionSkippedCorrectiveActions:
      type: array
      description: 'Present only when a `corrective_actions` entry was skipped: its
        status is one the dedicated corrective-action endpoints will not write (`cancelled`,
        or a change to a completed or cancelled action), or it asked to complete an
        action the company requires a photo for (`completion_photo_required`). Nothing
        in a skipped update entry was saved; a skipped create entry was added open
        (its `id` is the new action). Everything else in the request was saved.'
      items:
        type: object
        required:
        - index
        - status
        - reason
        - message
        properties:
          index:
            type: integer
            description: Position of the entry in the request's `corrective_actions`
              array.
          id:
            type: integer
            nullable: true
            description: 'The corrective action: for an update entry, or a create
              entry added open; otherwise null.'
          status:
            type: string
            example: cancelled
            description: The status the entry asked for.
          reason:
            type: string
            enum:
            - status_not_writable
            - completion_photo_required
          message:
            type: string
            example: 'This corrective action wasn''t changed: it can''t be set to
              cancelled in the app.'
    InspectionCompleteRequest:
      type: object
      description: Body for `POST /inspections/inspections/:id/complete`.
      properties:
        signature:
          type: string
          nullable: true
          description: |
            Inspector signature, base64-encoded PNG. May be a full data
            URI (`data:image/png;base64,...`) or a bare base64 string.
        notes:
          type: string
          nullable: true
          description: Final inspector notes to attach to the submission.
        corrective_actions:
          type: array
          description: |
            Optional follow-up actions to create (or update) atomically as part
            of the completion. Persisted BEFORE the state transition so the
            post-completion fan-out (`FormIntegrationService`) sees them and
            skips auto-creating duplicates for the same failed items. An entry
            whose status the dedicated endpoints will not write is skipped and
            listed in `skipped_corrective_actions`.
          items:
            "$ref": "#/components/schemas/InspectionCorrectiveActionCreateItem"
    InspectionCancelRequest:
      type: object
      description: Body for `POST /inspections/inspections/:id/cancel`.
      properties:
        reason:
          type: string
          nullable: true
          description: Free-form reason recorded in the audit log.
    InspectionTakeOverRequest:
      type: object
      description: Body for `POST /inspections/inspections/:id/take_over`.
      properties:
        reason:
          type: string
          nullable: true
          description: Free-form reason recorded in the reassignment audit entry.
    InspectionSyncRequest:
      type: object
      description: |
        Body for `POST /inspections/inspections/:id/sync`. Applies a batch of
        item updates + inspection-level patch fields to an in-progress
        inspection without completing it (unless `complete: true`).
      properties:
        client_id:
          type: string
          nullable: true
          description: Client-side idempotency token (typically a UUID).
        client_updated_at:
          type: string
          format: date-time
          nullable: true
          description: Accepted and IGNORED — a device clock, not comparable with
            server timestamps. For conflict detection send `base_version` on the inspection
            and on each item.
        inspection:
          "$ref": "#/components/schemas/InspectionSyncRequestInspection"
        items:
          type: array
          description: Item updates to apply (located by `id` or `template_item_id`).
          items:
            "$ref": "#/components/schemas/InspectionSyncRequestItem"
        media_uploads:
          type: array
          description: Direct-upload blob refs to attach to specific items.
          items:
            "$ref": "#/components/schemas/InspectionCreateMediaUpload"
        corrective_actions:
          type: array
          description: |
            Optional follow-up actions to create (or update) atomically in
            the same transaction as the item / media writes. A validation
            failure on ANY entry rolls back the entire sync (no partial
            writes); an entry whose status the dedicated endpoints will not
            write is skipped and listed in `skipped_corrective_actions`. When
            the sync also carries `complete: true`, CAs land BEFORE `submit!`
            so the post-completion fan-out skips creating duplicates for the
            same failed items.
          items:
            "$ref": "#/components/schemas/InspectionCorrectiveActionCreateItem"
        complete:
          type: boolean
          default: false
          description: When true, call `submit!` after applying the batch.
    InspectionSyncRequestItem:
      allOf:
      - "$ref": "#/components/schemas/InspectionCreateItem"
      - type: object
        properties:
          base_version:
            type: string
            nullable: true
            description: Optional. The item's `server_version` from the bundle or
              a previous response. When the server row changed after it, this item
              is NOT applied and is listed in the response's `conflicts`. Absent =
              last-write-wins. Not an ISO-8601 string = 422 `invalid_base_version`.
    InspectionSyncRequestInspection:
      type: object
      description: |
        Inspection-level patch carried inside `InspectionSyncRequest.inspection`.
        Mirrors the whitelist applied by `apply_inspection_updates!` on the
        server. `signature` is read here (not at the top level) and forwarded
        to `submit!` when `complete: true`.
      properties:
        notes:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
        signature:
          type: string
          nullable: true
          description: 'Base64 or stored ref; only consumed when `complete: true`.'
        gps_latitude:
          type: number
          nullable: true
        gps_longitude:
          type: number
          nullable: true
        gps_accuracy:
          type: number
          nullable: true
        gps_address:
          type: string
          nullable: true
        gps_captured_at:
          type: string
          format: date-time
          nullable: true
        base_version:
          type: string
          nullable: true
          description: Optional. The inspection's `server_version`. When stale, notes
            / summary / gps_* are NOT applied (item answers are guarded per item)
            and the inspection is listed in the response's `conflicts`.
    InspectionCorrectiveActionUpdateRequest:
      type: object
      description: |
        Body for `PATCH /inspections/corrective_actions/:id`. Every field
        is optional; only the keys present are applied.
      properties:
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - cancelled
          description: |
            `in_progress` on a Not started (`pending`) action is Start work: it
            goes through Capa::Action#start!, which records who started it on
            the action's trail. `completed` goes through Capa::Action#complete!.
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        description:
          type: string
        due_date:
          type: string
          format: date
          nullable: true
        assigned_to_id:
          type: integer
          nullable: true
        resolution_notes:
          type: string
          nullable: true
    DirectUploadBlobRequest:
      type: object
      description: |
        Pre-upload blob descriptor. The client SHA256-hashes the raw bytes
        and sends the digest as `checksum` so Active Storage can verify
        the eventual PUT to S3.
      properties:
        blob:
          type: object
          required:
          - filename
          - content_type
          - byte_size
          - checksum
          properties:
            filename:
              type: string
              example: photo-1716499200.jpg
            content_type:
              type: string
              description: |
                Normally one of the allow-listed image or video MIME types.
                When the top-level `non_media` flag is true, this allow-list is
                bypassed and any content type is accepted (the size cap still
                applies).
              enum:
              - image/jpeg
              - image/png
              - image/gif
              - image/webp
              - image/heic
              - image/heif
              - video/mp4
              - video/webm
              - video/quicktime
              - video/x-msvideo
            byte_size:
              type: integer
              description: |
                Raw byte length. Limits: ≤ 20 MB for photos, ≤ 50 MB for
                videos and (when `non_media` is true) any other content type.
              example: 2097152
            checksum:
              type: string
              description: Base64-encoded MD5 of the raw bytes (Active Storage convention).
              example: q2QmVgL5xK+oR2VBoXcb9Q==
        non_media:
          type: boolean
          default: false
          description: |
            Optional. When true, the `blob.content_type` allow-list is bypassed
            so any file type (e.g. a PDF report or document) can be uploaded.
            When absent or false, only the allow-listed image/video types are
            accepted. The size cap always applies (photo types ≤ 20 MB,
            everything else ≤ 50 MB).
          example: false
      required:
      - blob
    DirectUploadBlobResponse:
      type: object
      description: |
        Signed URL the client PUTs raw bytes to, plus the `signed_id`
        used later to attach the blob to an inspection item.
      properties:
        signed_id:
          type: string
          example: eyJfcmFpbHMi...
        direct_upload_url:
          type: string
          example: https://example-bucket.s3.amazonaws.com/abc?X-Amz-...
        direct_upload_headers:
          type: object
          additionalProperties:
            type: string
          description: HTTP headers the client must include on the PUT.
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        checksum:
          type: string
      required:
      - signed_id
      - direct_upload_url
      - direct_upload_headers
      - filename
      - content_type
      - byte_size
    InspectionMediaItem:
      type: object
      description: |
        One attached photo or video. Source:
        `Api::V1::Inspections::ItemsController#media_item_payload`.
      properties:
        id:
          type: integer
        media_kind:
          type: string
          enum:
          - photo
          - video
        content_type:
          type: string
        byte_size:
          type: integer
        original_filename:
          type: string
          nullable: true
        url:
          type: string
          nullable: true
        thumb_url:
          type: string
          nullable: true
        has_annotations:
          type: boolean
        captured_at:
          type: string
          format: date-time
          nullable: true
    InspectionScheduleSummary:
      type: object
      description: 'Source: `Api::V1::Inspections::ScheduleSerializer`.

        '
      properties:
        id:
          type: integer
        name:
          type: string
        frequency:
          type: string
          nullable: true
        day_of_week:
          type: integer
          nullable: true
        day_of_month:
          type: integer
          nullable: true
        anchor_month:
          type: integer
          nullable: true
          minimum: 1
          maximum: 12
          description: |
            The month a quarterly or annual schedule is anchored to. Annually: the
            month it runs in. Quarterly: stored as 1, 2 or 3, meaning
            Jan/Apr/Jul/Oct, Feb/May/Aug/Nov or Mar/Jun/Sep/Dec. Null for the
            other frequencies.
        due_months:
          type: array
          nullable: true
          items:
            type: integer
            minimum: 1
            maximum: 12
          description: The months (1-12) a quarterly or annual schedule runs in; null
            for the other frequencies.
        recurrence:
          type: string
          description: The schedule page's description of when it runs, e.g. "Quarterly
            on day 1 (Jan, Apr, Jul, Oct)".
        active:
          type: boolean
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        last_generated_at:
          type: string
          format: date-time
          nullable: true
        next_generation_at:
          type: string
          format: date-time
          nullable: true
        ready_to_generate:
          type: boolean
        template:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            category:
              type: string
              nullable: true
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        assigned_inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true
    InspectionCycleItem:
      type: object
      description: |
        A claimable cycle item on the "Available to claim" feed. An inspection
        only exists AFTER a claim, so the claim pool serializes the
        pre-inspection `InspectionCycleItem`. Source:
        `Api::V1::Inspections::CycleItemSerializer`.
      properties:
        id:
          type: integer
          description: Cycle item id.
          example: 4
        name:
          type: string
          nullable: true
          description: The thing being inspected.
          example: 'Forklift #3'
        cycle_id:
          type: integer
          example: 7
        cycle_name:
          type: string
          nullable: true
          example: Q3 Safety Sweep
        template_id:
          type: integer
          nullable: true
          example: 81
        template_name:
          type: string
          nullable: true
          example: Daily Workplace Safety Inspection
        location_id:
          type: integer
          nullable: true
          example: 2
        location_name:
          type: string
          nullable: true
          example: Northeast Region
        due_at:
          type: string
          format: date-time
          nullable: true
          description: Prospective deadline — the cycle's end-of-day (the `due_at`
            stamped on the inspection at claim time). Null when the cycle has no end
            date.
        status:
          type: string
          example: pending
        can_claim:
          type: boolean
          description: Server-authoritative. Always true in this feed (already filtered
            to the caller's claimable items).
          example: true
        claimed_by:
          type: object
          nullable: true
          description: The assigned inspector once claimed; null while in the pool.
          properties:
            id:
              type: integer
            name:
              type: string
      required:
      - id
      - cycle_id
      - status
      - can_claim
    InspectionCycleSummary:
      type: object
      description: 'Source: `Api::V1::Inspections::CycleSerializer`.

        '
      properties:
        id:
          type: integer
        name:
          type: string
        cycle_type:
          type: string
          nullable: true
        status:
          type: string
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        days_remaining:
          type: integer
          nullable: true
        progress_percentage:
          type: number
          format: float
          nullable: true
        close_behavior:
          type: string
          nullable: true
        template:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        my_inspection:
          type: object
          nullable: true
          description: The caller's in-progress or draft inspection for this cycle,
            if one exists. Lets the client show "Resume" instead of "Start". Null
            when the caller has no active inspection in the cycle.
          properties:
            id:
              type: integer
            status:
              type: string
    InspectionSweepSummary:
      type: object
      description: |
        Source: `Api::V1::Inspections::SweepSummarySerializer`. A coverage
        sweep the current inspector can work (one per facility layout).
      properties:
        id:
          type: integer
        status:
          type: string
          description: in_progress | pending_approval | signed_off
        status_label:
          type: string
          description: The state's one word, as every web surface shows it ("In progress",
            "Ready for sign-off", "Submitted for review", "Signed off"), in the caller's
            language. "Ready for sign-off" is an `in_progress` sweep with every aisle
            covered or skipped (InspectionSweep#display_status) — `status` stays `in_progress`
            until it is signed off.
        sweep_assignment:
          type: string
          description: claim_pool | zones
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        facility_layout:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        coverage_percent:
          type: integer
          nullable: true
        aisles_covered:
          type: number
          format: float
          nullable: true
        aisles_total:
          type: integer
          nullable: true
        fully_covered:
          type: boolean
          nullable: true
        last_session:
          type: object
          nullable: true
          description: Cross-person resume teaser; null until anyone has paused a
            session.
          properties:
            range_label:
              type: string
              nullable: true
            frontier_bay:
              type: integer
              nullable: true
            handoff_note:
              type: string
              nullable: true
            paused_by:
              type: string
              nullable: true
            paused_at:
              type: string
              format: date-time
              nullable: true
        handed_off_aisles:
          type: array
          description: Aisles started and left with nobody on them, in floor order
            — each can be picked up (POST claim_aisle with aisle_id).
          items:
            type: object
            properties:
              aisle_id:
                type: integer
              aisle_label:
                type: string
              frontier_bay:
                type: integer
                nullable: true
              handoff_note:
                type: string
                nullable: true
              left_by:
                type: object
                nullable: true
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                    nullable: true
              left_at:
                type: string
                format: date-time
                nullable: true
        next_step:
          type: string
          nullable: true
          enum:
          - continue
          - resume
          - pick_up
          - start
          - ready_for_sign_off
          - awaiting_sign_off
          - in_review
          - signed_off
          -
          description: 'What the caller does next on this sweep (InspectionSweep#next_step_for
            — the rule the web "Coverage sweeps" cards read). `continue`: the caller''s
            own session is still open on an aisle not yet covered. `ready_for_sign_off`
            / `awaiting_sign_off`: every aisle is covered or skipped, and the caller
            may (or may not) sign it off — never `start` at 100%. `in_review`: sent
            for review. A token without write:inspections is never `ready_for_sign_off`.'
        next_step_label:
          type: string
          nullable: true
          description: The line the web card draws for `next_step`, in the caller's
            language (e.g. "Nothing left to sweep · Ready for sign-off").
        next_step_action_label:
          type: string
          nullable: true
          description: The button beside it, in the caller's language (Continue, Resume,
            Pick up, Start, Sign off, View). "Sign off" opens the sweep, whose sign-off
            is POST /inspections/sweeps/{id}/sign_off.
        next_step_aisle_id:
          type: integer
          nullable: true
          description: The aisle the step is about — the caller's own aisle (`continue`),
            or the hand-off to `resume` / `pick_up` (POST claim_aisle with this aisle_id).
            Null otherwise.
        inspection_cycle:
          type: object
          nullable: true
          description: The parent cycle — its name is the "inspection name" the web
            shows (banner/breadcrumb/cockpit).
          properties:
            id:
              type: integer
            name:
              type: string
        inspection_cycle_id:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    InspectionSweepCell:
      type: object
      description: One resolved (bay, level) cell. Only passed/exception cells exist;
        a (bay,level) not present is "pending".
      properties:
        bay:
          type: integer
        level:
          type: integer
        status:
          type: string
          description: passed | exception
    InspectionSweepAisle:
      type: object
      description: 'Source: `Api::V1::Inspections::SweepAisleSerializer`. One aisle
        + its per-(bay,level) grid.'
      properties:
        id:
          type: integer
        label:
          type: string
        position:
          type: integer
        zone_name:
          type: string
          nullable: true
        status:
          type: string
          description: not_started | in_progress | covered | skipped
        bays_total:
          type: integer
        depth:
          type: integer
        frontier_bay:
          type: integer
        skip_reason:
          type: string
          nullable: true
        coverage_ratio:
          type: number
          format: float
        ready_to_cover:
          type: boolean
          description: Every bay is resolved but the aisle is not marked covered yet
            — prompt "Aisle N complete" with Mark aisle covered (POST …/aisles/{id}/cover)
            and Next aisle. Never cover on the inspector's behalf.
        bay_labels:
          type: array
          items:
            type: string
        level_elevations:
          type: array
          items: {}
        current_inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        assigned_inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        cells:
          type: array
          items:
            "$ref": "#/components/schemas/InspectionSweepCell"
    InspectionSweepException:
      type: object
      description: 'Source: `Api::V1::Inspections::SweepExceptionSerializer`. A coverage-sweep
        finding.'
      properties:
        id:
          type: integer
        aisle:
          type: string
          nullable: true
        aisle_id:
          type: integer
          nullable: true
        bay:
          type: string
          nullable: true
        level:
          type: string
          nullable: true
        component:
          type: string
          nullable: true
        damage:
          type: string
          nullable: true
        severity:
          type: string
          description: Observation | Minor | Major | Critical
        status:
          type: string
          description: open | resolved | corrective | voided
        note:
          type: string
          nullable: true
        answers:
          type: object
        position_label:
          type: string
          nullable: true
        inspector:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        photos:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              media_kind:
                type: string
              url:
                type: string
              captured_at:
                type: string
                format: date-time
                nullable: true
        created_at:
          type: string
          format: date-time
    InspectionSweepDetail:
      allOf:
      - "$ref": "#/components/schemas/InspectionSweepSummary"
      - type: object
        properties:
          can_write:
            type: boolean
          can_cover_aisles:
            type: boolean
            description: May mark an aisle covered (POST …/aisles/{id}/cover) — may
              write and the sweep is not signed off. Offer it on any aisle not covered
              or skipped.
          can_sign_off:
            type: boolean
            description: May sign off (SweepPolicy#can_sign_off? — the rule POST …/sign_off
              asks), not already signed off, and not already pending review. Open
              critical findings warn, they do not block.
          untracked_critical_count:
            type: integer
            description: Critical findings not yet resolved, voided, escalated or
              attached to a corrective action — the number the sign-off warning names.
          requires_approval:
            type: boolean
          aisles:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionSweepAisle"
          exceptions:
            type: array
            items:
              "$ref": "#/components/schemas/InspectionSweepException"
    InspectionSweepBundle:
      allOf:
      - "$ref": "#/components/schemas/InspectionSweepDetail"
      - type: object
        properties:
          rack_form_schema:
            type: object
            description: Component-branched log-issue form (RackFormSchema).
          notable_form_schema:
            type: object
            description: 'The notable-exception form — the cycle''s notable template,
              or the built-in simple form. Carries `component_optional: true` and
              `component_default` ("General"): a notable finding may name no component
              (send none or blank, on create and on edit) and is filed under the default.
              Pre-select nothing, and let a chosen component be cleared.'
          components:
            type: array
            items:
              type: string
          fetched_at:
            type: string
            format: date-time
          etag:
            type: string
    LeaderRoundsPagination:
      type: object
      description: Per-collection pagination. `/leader-rounds/my` returns three of
        these — one per collection — because it walks three independent cursors.
      required:
      - page
      - per_page
      - total
      - total_pages
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total:
          type: integer
          example: 42
        total_pages:
          type: integer
          example: 2
    LeaderRoundsRoundSummary:
      type: object
      description: A round as it appears in a list. `private_notes` is present ONLY
        for the leader who led it — on any other tier the key is absent, not null.
      required:
      - id
      - subject_id
      - subject_name
      - leader_id
      - leader_name
      - occurred_on
      - status
      properties:
        id:
          type: integer
          example: 4
        subject_id:
          type: integer
          example: 1752
        subject_name:
          type: string
          example: Marcus Bell
        subject_job_title:
          type: string
          nullable: true
          description: The subject's credential, null when the tenant records none.
            How a person is identified on a floor ("Marcus Bell, RN") — two Bells
            on one unit is normal, so a bare name is ambiguous.
          example: RN
        subject_avatar_url:
          type: string
          nullable: true
          description: Absolute URL — the photo, or a generated initials image when
            there is none.
        leader_id:
          type: integer
          example: 893
        leader_name:
          type: string
          example: Priya Raman
        leader_job_title:
          type: string
          nullable: true
          description: 'Emitted alongside the subject''s because the two surfaces
            that render a round show different people: a leader''s Recent Rounds shows
            the SUBJECT, Rounds About Me shows the LEADER.'
          example: Unit Leader
        leader_avatar_url:
          type: string
          nullable: true
          description: Absolute URL — the photo, or a generated initials image when
            there is none.
        template:
          type: string
          description: The template's NAME (not its id).
          example: Staff Rounding
        occurred_on:
          type: string
          format: date
          example: '2026-08-06'
        status:
          type: string
          enum:
          - draft
          - completed
          - skipped
          example: completed
        skipped_reason:
          type: string
          nullable: true
          description: Present on a skipped round. A skip is a recorded decision the
            subject can read — "subject on leave" — not a gap in coverage.
        answers_removed_on:
          type: string
          format: date
          nullable: true
          description: The day a request about the subject's personal data removed
            what was said — a year after they left. The round still counts toward
            its leader's coverage; it has no answers or notes to show. Null on every
            other round.
          example:
        private_notes:
          type: string
          nullable: true
          description: The round's OWN leader only, on this API. Absent from the payload
            entirely for everyone else — the subject, the leader's management chain
            and app admins (the web admits the chain; this door is deliberately narrower).
            See `private_notes_visible` on the detail response.
        answers_count:
          type: integer
          description: Answers recorded on the round. Emitted by GET /leader-rounds/rounds
            (batched for the page) and by the create and detail replies — not by /leader-rounds/my
            or the skip reply.
        issues_count:
          type: integer
          description: Issues the round raised onto the stoplight ledger. Emitted
            where `answers_count` is.
        substantive:
          type: boolean
          description: False for a completed round with every answer blank and no
            issue (the web's "No answers" badge — coverage does not count it, so the
            person stays due), and for a draft or a skip. On every round payload.
        open_issues_count:
          type: integer
          description: Of `issues_count`, how many are still open. LIST endpoints
            only. A round whose three issues are all resolved and one whose three
            are still open read identically from a total, and on a log of past rounds
            that difference is the point.
    LeaderRoundsTemplateQuestion:
      type: object
      description: One ordered, typed question. `field_type` is the platform form-field
        control to render; `question_type` is the key `answers` is submitted under.
        See the mapping table on GET /leader-rounds/templates.
      required:
      - id
      - position
      - prompt
      - question_type
      - field_type
      - required
      - choices
      - composite
      - inputs
      properties:
        id:
          type: integer
          description: The key to use in the `answers` / `issues` hash on create.
          example: 5
        position:
          type: integer
          example: 4
        prompt:
          type: string
          example: How supported do you feel in your role right now?
        question_type:
          type: string
          enum:
          - text
          - scale
          - boolean
          - choice
          - recognition_pick
          - issue_capture
          example: scale
        field_type:
          type: string
          enum:
          - textarea
          - rating
          - radio
          - select
          - lookup
          description: The shared form-field control this question renders on.
          example: rating
        required:
          type: boolean
          description: A required question gates the save. These are the "starred"
            answers on the capture form.
          example: true
        choices:
          type: array
          description: Populated only for `choice`; an empty array otherwise, so a
            client never has to branch on nil-vs-empty.
          items:
            type: string
          example: []
        scale_min:
          type: integer
          nullable: true
          description: Present only for `scale`. Published because the bound is enforced
            in the model — a client that guessed 1..10 would build a picker whose
            top half always 422s.
          example: 1
        scale_max:
          type: integer
          nullable: true
          example: 5
        composite:
          type: boolean
          description: 'True when this question needs MORE THAN ONE control. `field_type`
            names only the FIRST one, so a client that renders `field_type` alone
            silently omits the rest — which shipped: the native capture form drew
            a recognition_pick''s person picker and not its citation, and because
            the recognition post body is built from that citation, every recognition
            posted from mobile carried no reason. A client never has to branch on
            this flag, because `inputs` is authoritative either way.'
          example: true
        excludable_from_reporting:
          type: boolean
          description: 'Whether the leader may leave this question''s answer out of
            reporting by sending `exclude_from_reporting: true` in its `answers` hash
            — text, scale, yes/no and choice questions. Drawn on the web and phone
            forms as "Leave this answer out of themes and reports".'
          example: true
        exclude_from_reporting_label:
          type: string
          nullable: true
          description: The box's label, as the web and phone forms draw it; null when
            `excludable_from_reporting` is false.
          example: Leave this answer out of themes and reports
        exclude_from_reporting_help:
          type: string
          nullable: true
          description: The sentence the forms say once above the boxes — leaving an
            answer out does not hide it from anyone who can read the round; null when
            `excludable_from_reporting` is false.
          example: Leaving an answer out of themes and reports doesn't hide it from
            anyone who can read the round.
        inputs:
          type: array
          description: 'THE RENDER LIST — always present and always complete: one
            entry for a simple question, several for a composite. Render every entry
            and a composite cannot be half-built.'
          items:
            type: object
            required:
            - key
            - submit_as
            - field_type
            properties:
              key:
                type: string
                description: The submission key for this input, inside the question's
                  own hash. A simple question's is `value`.
                example: referenced_user_id
              submit_as:
                type: string
                enum:
                - answers
                - issues
                description: 'WHICH top-level hash this input posts under. Explicit
                  because it is the part most easily got wrong: an `issue_capture`''s
                  inputs go in `issues`, not `answers`, so a client that assumed one
                  envelope for everything would drop the whole issue.'
                example: answers
              field_type:
                type: string
                enum:
                - textarea
                - text
                - rating
                - radio
                - select
                - lookup
                - date
                example: lookup
              label:
                type: string
                example: Owner
              prompt:
                type: string
                description: The parent question's prompt, repeated for convenience.
              placeholder:
                type: string
                nullable: true
              help:
                type: string
                nullable: true
              required:
                type: boolean
                example: false
              lookup_source:
                type: string
                nullable: true
                description: For `lookup` inputs — the record set to search.
                example: users
              choices:
                type: array
                items:
                  type: string
                example:
                - low
                - medium
                - high
                - critical
              default:
                type: string
                nullable: true
                description: Server-side default when the input is left blank.
                example: medium
              max_length:
                type: integer
                nullable: true
                description: Published only where the server enforces it.
                example: 2000
    LeaderRoundsAnswer:
      type: object
      description: One answer on a round. `value` is the formatted display string;
        the typed columns ride alongside for clients that render the raw value (a
        scale drawn as filled pips rather than the text "3").
      required:
      - id
      - question_id
      - prompt
      - question_type
      - field_type
      - position
      properties:
        id:
          type: integer
          description: The ANSWER's id — what every write naming an answer takes,
            including `POST /leader-rounds/rounds/{id}/recognize`. DISTINCT from `question_id`,
            which identifies the template question this answers; sending that one
            instead is a lookup miss, not a match.
          example: 277
        question_id:
          type: integer
          example: 5
        prompt:
          type: string
          example: How supported do you feel in your role right now?
        question_type:
          type: string
          enum:
          - text
          - scale
          - boolean
          - choice
          - recognition_pick
          - issue_capture
        field_type:
          type: string
          enum:
          - textarea
          - rating
          - radio
          - select
          - lookup
        required:
          type: boolean
        position:
          type: integer
        value:
          type: string
          nullable: true
          description: Formatted for display — "4 of 5" for a rating, "Yes"/"No" for
            boolean, the name for a recognition pick.
          example: 4 of 5
        value_text:
          type: string
          nullable: true
        value_number:
          type: number
          nullable: true
          description: The scale value.
        value_boolean:
          type: boolean
          nullable: true
        excluded_from_reporting:
          type: boolean
          description: 'The leader left this answer out of reporting when they recorded
            the round: it is on the round, and in no theme, AI theme or engagement
            score.'
          example: false
        referenced_user_id:
          type: integer
          nullable: true
          description: For a `recognition_pick` — the colleague named.
        referenced_user_name:
          type: string
          nullable: true
          example: Amy Okonkwo
        recognition_posted:
          type: boolean
          nullable: true
          description: 'True once the pick has been posted to the recipient''s feed.
            Clients use this to show "posted" rather than offering the one-shot button
            again — a re-save must never spam the recipient. Null for the person rounded
            on, like `recognition_status`: whether their leader''s recognition of
            a colleague was posted is not theirs to see (it stays true after the post
            is rejected or removed).'
          example: false
        recognition_status:
          type: string
          nullable: true
          enum:
          - active
          - pending_approval
          - pending_review
          - rejected
          - hidden
          - deleted
          -
          description: The posted recognition's own status — `pending_approval` while
            a manager still has it in their queue, `active` once it is live. Null
            when nothing was posted, when the post no longer exists, and always for
            the person the round was about (its moderation outcome is not theirs to
            see).
          example:
    LeaderRoundsWarningDetail:
      type: object
      description: One save warning — the round or recognition saved, but part of
        the submission did not land as sent.
      required:
      - code
      - message
      - question_id
      properties:
        code:
          type: string
          enum:
          - payload_not_keyed
          - issue_description_shortened
          - recognition_pick_inactive
          - issue_sent_as_answer
          - issues_capped
          - issue_owner_inactive
          - due_date_too_far
          - due_date_in_past
          - due_date_unreadable
          - recognition_marker_not_saved
          - value_not_scalar
          - warnings_not_replayed
          - exclusion_not_supported
          example: due_date_in_past
        message:
          type: string
          description: The same English sentence `warnings` carries.
          example: A due date of Sep 29, 2026 is in the past — it was set to today.
        question_id:
          type: integer
          nullable: true
          description: The template question the warning concerns, when it concerns
            one.
          example: 1234
    LeaderRoundsIssue:
      type: object
      description: One row on the stoplight ledger — a `Capa::Action` sourced from
        a round. `stoplight` is DERIVED at read time, never stored.
      required:
      - id
      - description
      - status
      - stoplight
      properties:
        id:
          type: integer
          example: 614
        description:
          type: string
          example: Vitals machine in bay 3 still out of service
        status:
          type: string
          description: The status on the CAPA register.
          example: in_progress
        stoplight:
          type: string
          enum:
          - red
          - yellow
          - green
          description: 'Derived: green = completed, red = cancelled OR open-past-due,
            yellow = the open remainder. A CANCELLED issue is red but is a decision
            — label it "Won''t fix", not "Red".'
          example: yellow
        due_date:
          type: string
          format: date
          nullable: true
          example: '2026-08-31'
        assigned_to_id:
          type: integer
          nullable: true
        assigned_to_name:
          type: string
          nullable: true
          example: Dana Whitfield
        owner_active:
          type: boolean
          description: False when the owner has left the account (deactivated, or
            no active membership here) — the issue stays on the ledger with their
            name, so draw it as "no longer active here" rather than as work someone
            is chasing. True for an unassigned issue.
          example: true
        resolution_notes:
          type: string
          nullable: true
          description: Required when cancelling, and PUBLISHED to the person who raised
            the issue. This is the guardrail that keeps the ledger honest.
        round_id:
          type: integer
          description: The round this was raised in — tap-through target.
          example: 4
        pillar_id:
          type: integer
          nullable: true
          description: 'The strategic pillar this issue rolls up to. Resolved at read
            time: an explicit per-issue override wins, otherwise it is derived from
            the template question that raised the issue. `null` means Unaligned —
            either no override and an unmapped question, or an override deliberately
            pinning the issue to Unaligned. Settable via `PATCH /leader-rounds/issues/{id}`.'
          example: 67
        pillar_name:
          type: string
          nullable: true
          description: Display name of `pillar_id`, resolved for the caller so a client
            does not need a second lookup. `null` whenever `pillar_id` is null.
          example: People, Culture & Leadership
        priority:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
          nullable: true
          description: As the leader set it on capture. Anything outside the enum
            is clamped to `medium` server-side, so a client never has to defend against
            a value it cannot render.
          example: high
        raised_by_name:
          type: string
          nullable: true
          description: The round's SUBJECT — the person who raised it. Deliberately
            not `created_by`, which RoundCreator sets to the round's LEADER unconditionally
            and would therefore name the same person on every row of that leader's
            own ledger. The web ledger links this same subject.
          example: Tasha Green
        created_by_name:
          type: string
          nullable: true
          description: 'WHO HANDED IT OVER — the round''s leader, who created the
            issue. A DIFFERENT person from `raised_by_name` (the round''s subject),
            and the one an assignee needs: nothing else in the payload says who gave
            them the work. Rendered as "Assigned by" on the subject screen.'
          example: Priya Raman
        round_occurred_on:
          type: string
          format: date
          nullable: true
          description: When the round took place, so a row can say WHEN something
            was raised rather than naming the raiser with no context.
          example: '2026-07-08'
        round_visible:
          type: boolean
          description: Whether the caller may OPEN the round this issue came from
            — a narrower question than whether they may read the issue, and not answerable
            from anything else in the payload. `GET /leader-rounds/issues/{id}` admits
            an issue merely ASSIGNED to the caller; `GET /leader-rounds/rounds/{id}`
            does not (an assignment grants no access to a round holding private notes
            and answers about a third party). A client must draw the link back to
            the round ONLY when this is true — otherwise it offers a route that always
            404s. Emitted on the single-issue read AND on both issue writes (PATCH
            `/issues/{id}` and `/issues/{id}/status`), because a detail screen folds
            a write reply back into the record it is displaying — a reply that omitted
            it made the link to the round disappear the moment a status was saved.
            No LIST payload carries it (a query per row).
          example: true
        read_only:
          type: boolean
          description: Whether the CALLER may move this issue. COMPUTED per caller
            against the write tier (the rounds they lead or supervise) — being the
            ASSIGNEE grants nothing. A client draws a status control only when this
            is false; when it is true the absence is the point, and the surface should
            say who does own the status. Emitted on the single-issue read, on both
            issue writes (same fold-back reason as `round_visible`), and on the assigned-to-me
            rows of /my.
          example: false
    LeaderRoundsIssueHistory:
      type: array
      description: |-
        The CAPA action's audit trail: the notes IssueStatusUpdater writes atomically with each status change, plus any progress note posted through `POST /leader-rounds/issues/{id}/comments`. So the dates are REAL transitions rather than a summary composed afterwards.
        AN ENVELOPE KEY, not a property of `issue` — carried by the single-issue read and by the status write, and by nothing else. The list endpoints omit it because it is a query per row, which is why a detail screen has to fetch the record rather than render the row it was handed. Top-level notes only (replies are nested under their parent and not returned here) and soft-deleted notes are excluded.
      items:
        type: object
        properties:
          id:
            type: integer
          author:
            type: string
            nullable: true
            description: Full name, or null if the author's user record is gone.
          author_avatar_url:
            type: string
            nullable: true
            description: Absolute URL, or null when the author has no photo (or no
              user record). `POST /leader-rounds/issues/{id}/comments` returns a `comment`
              in this SAME shape, so a client can append its reply straight into the
              trail rather than re-fetching.
          body:
            type: string
            example: 'Status changed: In progress → Completed — Replaced from spare
              pool.'
          created_at:
            type: string
            format: date-time
    LeaderRoundsScopeMetric:
      type: object
      description: One labelled number on a scope-tree node. DELIBERATELY GENERIC
        — the client component that renders the picker must not need to know what
        is being counted, so it can be reused by the next app that grows a drill-down.
        Labels are server-authored and already localized.
      required:
      - label
      - value
      - tone
      properties:
        label:
          type: string
          example: Red
        value:
          type: string
          description: Pre-formatted for display, so the client does not re-format.
          example: '3'
        tone:
          type: string
          enum:
          - neutral
          - warning
          - danger
          - success
          description: Drives emphasis only; never the sole carrier of meaning.
          example: danger
    LeaderRoundsScopeNode:
      type: object
      description: One row in the scope picker — a leader and their subtree's numbers.
      required:
      - id
      - name
      - leaf
      - metrics
      properties:
        id:
          type: integer
          example: 1222
        name:
          type: string
          example: Priya Raman
        leaf:
          type: boolean
          description: No layer below. A leaf row APPLIES immediately rather than
            drilling into an empty level.
          example: false
        metrics:
          type: array
          description: Aggregated over the node's WHOLE subtree, matching the rollup's
            unit rows.
          items:
            "$ref": "#/components/schemas/LeaderRoundsScopeMetric"
        quiet:
          type: boolean
          description: Nothing anywhere in this node's subtree. These rows are ordered
            LAST (never dropped — the picker is the only way to reach a node, and
            on the rollup a team that raises nothing is often the coverage gap worth
            opening), so a client may collapse them behind a "show all" that states
            how many it is hiding.
          example: false
    AiFrontOfficeBookingRequest:
      type: object
      description: One booking request as the review queue lists it.
      properties:
        id:
          type: integer
        status:
          type: string
          example: declined
        source:
          type: string
        urgency:
          type: string
        service_type_key:
          type: string
          nullable: true
        customer_name:
          type: string
          nullable: true
        customer_phone:
          type: string
          nullable: true
        customer_email:
          type: string
          nullable: true
        address:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        requested_window_start:
          type: string
          format: date-time
          nullable: true
        requested_window_end:
          type: string
          format: date-time
          nullable: true
        confidence_score:
          type: number
          nullable: true
        created_at:
          type: string
          format: date-time
        first_contact_at:
          type: string
          format: date-time
          nullable: true
        converted_work_order_id:
          type: integer
          nullable: true
        assigned_dispatcher_id:
          type: integer
          nullable: true
        sla_status:
          type: string
          nullable: true
          description: Null where no response countdown applies (a declined request
            has none).
    AiInterviewRefusal:
      type: object
      description: |
        A refusal, in the platform's envelope. `details.ai_interview` is the
        conversation as it stands, so a client redraws from the refusal
        itself. Codes: `conversation_closed` (409 — the app's door shut),
        `not_taking_turns` (409; on POST …/speak `details.transcript` carries
        what the clip said, as for `busy`), `busy` (409 — a reply is still being
        written; on POST …/speak `details.transcript` carries what the clip said, already
        transcribed, so a client keeps the words), `reply_failed` (502 — the words are saved; POST …/retry),
        `ai_usage_blocked`, `not_reviewing` (409), `already_sent` (409),
        `not_deleted` (409), `unknown_language`, `audio_missing`, `no_speech`,
        `clip_too_large` (413), `voice_unavailable` (503),
        `recording_not_offered`, and the engine's own: `say_something`,
        `moved_on`, `choose_shown`, `needs_answer`, `write_something`,
        `write_an_answer`, `unknown_question`, `unknown_answer`,
        `unknown_verdict`, `quote_choice`, `choose_one`, `choose_some`,
        `felt_captured`, `nothing_to_change`; `private_conversation` (403 —
        an impersonation token, or a web session viewing as the person: a
        conversation is private to the person it belongs to, so it is never
        shown, started, answered or sent while acting as them;
        `details.ai_interview` is then only the list's line for it, with its
        status — GET /ai_interviews still lists them); `invalid` (422) for what could
        not be saved, in the message the page would show; on a send,
        `missing_required` (with `details.missing`), `already_participated`
        and `closed` (409), `invalid`, `not_sent` (refused with no code of its
        own), or the app's own refusal under its code.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: busy
            message:
              type: string
              example: Still thinking about your last answer…
            details:
              type: object
              properties:
                ai_interview:
                  "$ref": "#/components/schemas/AiInterview"
                missing:
                  type: array
                  items:
                    type: string
                  description: On a refused send, the item keys still needing an answer.
    AiInterviewResponse:
      type: object
      properties:
        ai_interview:
          "$ref": "#/components/schemas/AiInterview"
    AiInterviewSummary:
      type: object
      properties:
        id:
          type: integer
        purpose:
          type: string
          example: Your exit interview
        app:
          type: string
          nullable: true
          description: The app the conversation is for (its slug).
          example: offboarding
        status:
          type: string
          enum:
          - in_progress
          - coding
          - reviewing
          - submitted
        started_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        submitted_on:
          type: string
          format: date
          nullable: true
        web_url:
          type: string
          description: The conversation's page in a browser.
    AiInterview:
      allOf:
      - "$ref": "#/components/schemas/AiInterviewSummary"
      - type: object
        properties:
          screen:
            type: string
            enum:
            - opening
            - conversation
            - coding
            - review
            - done
            - closed
            description: 'What the page shows: the opening (the consent), the conversation,
              coding (the review is being drafted — poll), the review, done, or closed
              — the app''s own door shut on an unsent conversation, which may still
              be deleted.'
          rehearsal:
            type: boolean
          language:
            type: string
            example: en
          return_url:
            type: string
            nullable: true
            description: The app's own page Done leads to — another page the app names
              once its door has shut.
          closed_reason:
            type: string
            nullable: true
            description: 'On `screen: "closed"`, the app''s own reason its door shut
              (conversations paused, the plan cancelled) — shown in place of the generic
              sentence.'
          closed_alternative:
            type: object
            nullable: true
            description: 'On `screen: "closed"`, the app''s own way to answer (a form)
              when its reason offers it — the form still takes the record.'
            properties:
              label:
                type: string
                example: Fill in the feedback form instead
              web_url:
                type: string
          unsent_note:
            type: string
            nullable: true
            description: On the conversation, coding and review screens, how long
              an unsent conversation waits to be sent (or the app's own deadline).
          about:
            type: object
            description: The conversation's answers about itself.
            properties:
              who_sees:
                type: string
              what_happens:
                type: string
              reach_a_person:
                type: string
              after_sending:
                type: string
          opening:
            "$ref": "#/components/schemas/AiInterviewOpening"
          turns:
            type: array
            items:
              "$ref": "#/components/schemas/AiInterviewTurn"
          progress:
            type: array
            description: Topic progress; the fixed questions fold into one "Quick
              questions" row.
            items:
              type: object
              properties:
                key:
                  type: string
                label:
                  type: string
                state:
                  type: string
                  enum:
                  - current
                  - upcoming
                  - covered
                  - declined
                  - not_reached
                  - not_applicable
                  - inferred
          composer:
            allOf:
            - "$ref": "#/components/schemas/AiInterviewComposer"
            nullable: true
            description: On the conversation screen only.
          review:
            allOf:
            - "$ref": "#/components/schemas/AiInterviewReview"
            nullable: true
            description: On the review screen only.
    AiInterviewOpening:
      type: object
      description: The opening screen — also the consent.
      properties:
        minutes:
          type: integer
          example: 8
        topics:
          type: array
          items:
            type: string
        conditional_topics:
          type: array
          items:
            type: string
          description: The topics (from `topics`) asked only when an earlier answer
            meets their condition — shown as depending on an earlier answer, never
            promised outright.
        quick_questions:
          type: integer
          description: How many fixed questions are answered by tapping.
        quick_questions_up_to:
          type: boolean
          description: True when some quick questions are asked only on certain earlier
            answers, so `quick_questions` is an upper bound ("Up to N").
        facts:
          type: array
          description: What the interviewer was told about the person — shown to them.
          items:
            type: object
            properties:
              label:
                type: string
                example: Role
              value:
                type: string
                example: Line Lead
        some_required:
          type: boolean
          description: Some questions need an answer; any others may be skipped.
        required_questions:
          type: integer
          example: 1
          description: How many questions need an answer (0 when any may be skipped).
        required_share:
          type: string
          enum:
          - none
          - one
          - few
          - most
          - all
          example: most
          description: How much of the conversation needs an answer, as the opening
            says it — none (any question may be skipped), one, a few, most (more than
            half of the questions it can ask) or all (none may be skipped).
        alternative:
          type: object
          nullable: true
          description: The app's own way to answer instead (a form) — null while that
            form does not take the record (a shift past its feedback window).
          properties:
            label:
              type: string
              example: Fill in the form instead
            web_url:
              type: string
    AiInterviewTurn:
      type: object
      properties:
        sequence:
          type: integer
        role:
          type: string
          enum:
          - interviewer
          - participant
        kind:
          type: string
          enum:
          - opening
          - question
          - measure_prompt
          - answer
          - addition
          - followup
          - reflection
          - notice
          - sitting_end
          - closing
        text:
          type: string
        item_key:
          type: string
          nullable: true
    AiInterviewComposer:
      type: object
      properties:
        transports:
          type: array
          items:
            type: string
            enum:
            - text
            - recording
          description: How an answer may be sent now (POST …/turn, …/speak).
        awaiting_reply:
          type: boolean
          description: The person's last words have no reply — POST …/retry.
        between_sittings:
          type: boolean
          description: One topic per sitting — this one is done; POST …/begin starts
            the next.
        next_topic:
          type: string
          nullable: true
        item:
          allOf:
          - "$ref": "#/components/schemas/AiInterviewItem"
          nullable: true
          description: The question being asked now.
    AiInterviewItem:
      type: object
      properties:
        key:
          type: string
        kind:
          type: string
          enum:
          - exploration
          - measure
        label:
          type: string
        prompt:
          type: string
        required:
          type: boolean
        can_skip:
          type: boolean
        scale:
          type: string
          nullable: true
          description: 'A measure''s: nps, rating, boolean, single, multiselect …'
        multiple:
          type: boolean
        options:
          type: array
          items:
            type: object
            properties:
              value:
                oneOf:
                - type: string
                - type: integer
              label:
                type: string
        min_label:
          type: string
          nullable: true
        max_label:
          type: string
          nullable: true
        clarification:
          type: string
          nullable: true
        pending_value:
          description: A typed answer waiting to be confirmed by a tap.
          oneOf:
          - type: string
          - type: integer
          - type: array
            items:
              type: string
    AiInterviewReview:
      type: object
      description: '"Here''s what we''ll record."'
      properties:
        missing:
          type: array
          description: Questions the app needs answered before it can be sent (PATCH
            …/review with `missing_item`).
          items:
            "$ref": "#/components/schemas/AiInterviewItem"
        topics:
          type: array
          items:
            "$ref": "#/components/schemas/AiInterviewAnswer"
        quick_answers:
          type: array
          items:
            "$ref": "#/components/schemas/AiInterviewAnswer"
        quote_choices:
          type: array
          items:
            type: string
            enum:
            - none
            - deidentified
            - with_name
        quote_question:
          type: boolean
          description: 'Whether to ask "May we quote your words?" — true only while
            an answer that will be recorded has words to quote (and the app quotes
            anyone). When false, ask nothing: the consent stays as it is, and no quote
            is kept. `quote_choices` stays the set an answer is checked against.'
        quote_reader:
          type: string
          nullable: true
          description: Who reads a quote, when the app names one person (a team conversation's
            leader).
        quote_consent:
          type: string
          enum:
          - none
          - deidentified
          - with_name
        who_sees_now:
          type: string
          nullable: true
          description: Who reads the answers, when that changed since the person started
            (their manager became an administrator) — said again before Send.
        felt_captured:
          type: integer
          nullable: true
        can_submit:
          type: boolean
    AiInterviewAnswer:
      type: object
      properties:
        id:
          type: integer
        item_key:
          type: string
        kind:
          type: string
          enum:
          - exploration
          - measure
        status:
          type: string
          enum:
          - proposed
          - confirmed
          - edited
          - removed
        label:
          type: string
        can_remove:
          type: boolean
        summary:
          type: string
          nullable: true
          description: An exploration's — what will be recorded.
        codes:
          type: array
          items:
            type: string
          description: The themes it was counted under
          in plain words.:
        quotes:
          type: array
          items:
            type: string
          description: Words that might be quoted
          with details removed.:
        prompt:
          type: string
          description: A measure's question.
        scale:
          type: string
          description: 'A measure''s: nps, rating, boolean, single, multiselect …'
        multiple:
          type: boolean
          description: A measure's answer is a list of options (`value` an array).
        value:
          description: A measure's answer.
          oneOf:
          - type: string
          - type: integer
          - type: array
            items:
              type: string
        value_label:
          type: string
          nullable: true
        options:
          type: array
          items:
            type: object
            properties:
              value:
                oneOf:
                - type: string
                - type: integer
              label:
                type: string
    NotepadErrorBody:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
    NotepadValidationErrorBody:
      type: object
      properties:
        errors:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              message:
                type: string
    NotepadCursorMeta:
      type: object
      description: Keyset pagination. `next_cursor` is null on the last page.
      properties:
        limit:
          type: integer
        has_more:
          type: boolean
        next_cursor:
          type: integer
          nullable: true
    NotepadListMeta:
      allOf:
      - "$ref": "#/components/schemas/NotepadCursorMeta"
      - type: object
        description: The meetings list's `meta`. ONE key set across both paging modes,
          with an explicit null where a key does not apply — so a client can declare
          a single type for the endpoint instead of treating every key as optional.
        properties:
          page:
            type: integer
            nullable: true
            description: Null in keyset mode, which has no page number.
          filters:
            type: object
            description: 'What the page was actually computed under. Values are the
              APPLIED ones, not the raw params: an omitted narrowing echoes null rather
              than claiming something that never happened. The cursor is a bare meeting
              id carrying no filter identity, so this is how a client detects a stale
              cursor under changed filters.'
            properties:
              view:
                type: string
                nullable: true
                description: "`trash` when Recently Deleted was asked for; null otherwise."
              sort:
                type: string
                nullable: true
                description: One of `edited` / `created` / `title`; null in keyset
                  mode.
              q:
                type: string
                nullable: true
              status:
                type: string
                nullable: true
              filter:
                type: string
                nullable: true
              notebook_id:
                type: integer
                nullable: true
          pagination:
            type: object
            description: Which paging mode answered, and what it cost the caller.
            properties:
              mode:
                type: string
                enum:
                - cursor
                - page
                description: "`page` when `sort` was sent, `cursor` otherwise. The
                  two are exclusive: a keyset cursor over a non-id ordering skips
                  and duplicates rows."
              ignored:
                type: array
                description: The paging params the caller SENT that this mode discarded.
                  Empty when the request named one mode cleanly.
                items:
                  type: string
                  enum:
                  - cursor
                  - page
    NotepadSectionName:
      type: string
      description: The SERVER section vocabulary. Note `insights`, not the client's
        `key_insights` tab name.
      enum:
      - summary
      - action_items
      - insights
    NotepadUserCompact:
      type: object
      description: Enough identity to render an avatar and a name.
      properties:
        id:
          type: integer
        name:
          type: string
        initials:
          type: string
        color:
          type: string
          description: Deterministic avatar colour.
        avatar_url:
          type: string
          nullable: true
    NotepadAssignee:
      type: object
      description: Like NotepadUserCompact but keyed `user_id` — this is an assignment,
        not a user record.
      properties:
        user_id:
          type: integer
        name:
          type: string
        initials:
          type: string
        color:
          type: string
        avatar_url:
          type: string
          nullable: true
    NotepadNotebookRef:
      type: object
      nullable: true
      description: Compact notebook reference embedded in a meeting. `name` and `title`
        are the SAME value — the notebook's `title` column — emitted twice on purpose
        so this embed and the `/notebooks` list overlap and a client can declare one
        Notebook type across both.
      properties:
        id:
          type: integer
        name:
          type: string
        title:
          type: string
          description: Alias of `name`; always identical.
    NotepadNotebook:
      type: object
      description: The `/notebooks` list row. Carries `name` alongside `title` for
        the same reason NotepadNotebookRef carries `title` — same value, both keys,
        so the two shapes of one entity overlap.
      properties:
        id:
          type: integer
        name:
          type: string
          description: Alias of `title`; always identical.
        title:
          type: string
        description:
          type: string
          nullable: true
        note_count:
          type: integer
        can_delete:
          type: boolean
          description: Whether DELETE /ai_notepad/notebooks/{id} will accept this
            caller (the creator, or an "owner" collaborator). This list also holds
            notebooks the caller can only edit, so hide Delete when false instead
            of offering a door that answers 403.
        can_file:
          type: boolean
          description: Whether a note may be filed into this notebook. False for a
            notebook shared view-only (listed only with `include=readable`) and for
            a token without `write:ai_notepad`.
        updated_at:
          type: string
          format: date-time
    NotepadMeetingRef:
      type: object
      description: Lightweight meeting reference for deep-linking.
      properties:
        id:
          type: integer
        title:
          type: string
          nullable: true
          description: Null when `can_open` is false — the caller holds an action
            item on a note nobody shared with them, and a note's title goes only to
            someone who can open it.
        can_open:
          type: boolean
          description: Whether the caller may open the note. When false, show the
            date and "a note that isn't shared with you", and do not link the row.
        status:
          type: string
          enum:
          - processing
          - completed
          - failed
        date_label:
          type: string
        occurred_at:
          type: string
          format: date-time
          nullable: true
    NotepadMeetingSummary:
      type: object
      description: The list row. Carries denormalized counts and `artifacts_status`
        so a list render needs no per-row fan-out.
      properties:
        detail:
          type: boolean
          description: 'THE SHAPE DISCRIMINATOR, always present, so a typed client
            branches on it instead of probing for a key. `false` on this shape: the
            detail-only keys of NotepadMeetingDetail are ABSENT rather than null.'
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - processing
          - completed
          - failed
          description: Client vocabulary — see the file header for the mapping.
        source:
          type: string
          enum:
          - system_audio
          - voice_note
          - upload
          - paste
          - quick_note
          description: Client vocabulary — see the file header for the mapping.
        notebook_id:
          type: integer
          nullable: true
        notebook:
          "$ref": "#/components/schemas/NotepadNotebookRef"
        account:
          type: object
          nullable: true
          description: The Mango CS account the note is linked to, or null (ISS-20260910-524-C3B792).
            Set with `POST /ai_notepad/meetings/{id}/account`, cleared with the DELETE
            twin; pick from `GET /ai_notepad/meetings/linkable_accounts`.
          properties:
            id:
              type: integer
            name:
              type: string
        occurred_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        date_label:
          type: string
          description: Server-rendered human date, so surfaces cannot disagree.
        duration_sec:
          type: integer
          nullable: true
        excerpt:
          type: string
          nullable: true
          description: 'Short plain-text preview for the row, computed the same way
            the web list computes it (Apps::AiNotepadHelper#ai_notepad_note_excerpt):
            the opening block dropped when it merely repeats the title, tags stripped,
            truncated on a word boundary, and "Processing…" while the AI pipeline
            runs. Empty string when the note has no body.'
        has_ai_lifecycle:
          type: boolean
          description: False for a plain typed note — it has no processing state to
            show.
        pinned:
          type: boolean
        pinned_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
        action_items_open_count:
          type: integer
        action_items_total_count:
          type: integer
        artifacts_status:
          type: string
          enum:
          - pending
          - ready
          - failed
        created_by:
          allOf:
          - "$ref": "#/components/schemas/NotepadUserCompact"
          nullable: true
    NotepadMeeting:
      allOf:
      - "$ref": "#/components/schemas/NotepadMeetingSummary"
      description: The shape write endpoints echo back. Same fields as the list row.
    NotepadSpeakerSuggestion:
      type: object
      description: One "Who is Speaker A?" suggestion.
      properties:
        speaker_id:
          type: string
          description: The roster id it is for (`them_a`, `them_b`, … or `them`).
          example: them_a
        label:
          type: string
          description: The roster's own name for that speaker ("Speaker A", "Others
            on the call").
        name:
          type: string
          description: The invitee proposed.
          example: Micki Chen
        email:
          type: string
          nullable: true
          description: The invitee's address, when the invite carries one.
        evidence:
          type: string
          description: The transcript's words, verbatim, that show who it is.
          example: Thanks, Micki — can you walk us through the renewal terms?
    NotepadSpeakerNaming:
      type: object
      description: What a client redraws after a speaker is named, a suggestion accepted
        or one dismissed.
      properties:
        speakers:
          type: array
          items:
            type: object
            additionalProperties: true
        speaker_suggestions:
          type: array
          items:
            "$ref": "#/components/schemas/NotepadSpeakerSuggestion"
        speaker_names_changed:
          type: boolean
          description: See the meeting detail's `speaker_names_changed`.
    NotepadMeetingEntity:
      allOf:
      - "$ref": "#/components/schemas/NotepadMeetingSummary"
      - type: object
        description: 'The SERIALIZER''s full meeting shape — the list row plus the
          detail-only keys. Signed/expiring media URLs and the share-link state are
          NOT here: the serializer stays request-agnostic and the detail controller
          merges those on top (see NotepadMeetingDetail).'
        properties:
          detail:
            type: boolean
            description: Always `true` on this shape. See NotepadMeetingSummary.
          language:
            type: string
            nullable: true
          error_message:
            type: string
            nullable: true
            description: Present only when `status` is `failed`.
          notes:
            type: string
            nullable: true
            description: Server-derived markdown twin of `notes_html`. Null for a
              recording-backed meeting.
          notes_html:
            type: string
            nullable: true
            description: The canonical rich-text body.
          copy_text:
            type: string
            description: The whole note as the web's "Copy notes" puts it on the clipboard
              — My notes, summary, key insights and action items as Markdown, in page
              order. Empty string when there is nothing to copy, so a client can disable
              its Copy control. Present for every source. A recording's typed notes
              appear here under "Notes" even though `notes` and `notes_html` are null
              for a recording.
          can_edit:
            type: boolean
            description: Whether THIS caller may edit the content (owner/editor),
              so the client can gate its edit affordances instead of showing an Edit
              button that 403s.
          can_delete:
            type: boolean
            description: Whether this caller may delete the note (DELETE /ai_notepad/meetings/{id})
              and restore it from Recently Deleted — the creator, or an "owner" collaborator.
              A shared editor or viewer, or someone reading through a notebook, gets
              false; hide Delete instead of offering a door that answers 403.
          can_manage_sharing:
            type: boolean
            description: Whether this caller may open the Share dialog. A DIFFERENT
              right from `can_edit` — the members endpoints 403 a viewer — and the
              same predicate the web view gates on, so a client never offers a door
              the server refuses.
          can_change_notebook:
            type: boolean
            description: Whether this caller may move the note to another notebook
              (PATCH /ai_notepad/meetings/{id}/notebook). Filing a note into a notebook
              hands that notebook's audience the note, so this is narrower than `can_edit`;
              gate the notebook picker on it.
          summary_available:
            type: boolean
          insights_available:
            type: boolean
          has_audio_overview:
            type: boolean
          has_recording:
            type: boolean
          has_transcript:
            type: boolean
          speakers:
            type: array
            description: The speaker roster. Every element carries an `id` (`s1`,
              `s2`, …) that a transcript segment's `speaker` resolves against — derived
              from the element's position when the stored roster has none.
            items:
              type: object
              additionalProperties: true
          speaker_suggestions:
            type: array
            description: '"Who is Speaker A?" After processing, one AI pass proposes
              an invitee for each far-side speaker nobody has named — only with words
              from the transcript that show it (they introduce themselves, or are
              addressed by name and answer), never from the invite list alone. These
              are the live ones: pending, for a speaker still unnamed, made from the
              current transcript. Offer them to an editor (`can_edit`) through POST
              /ai_notepad/meetings/{id}/speakers/{speaker_id}/suggestion/accept and
              …/dismiss.'
            items:
              "$ref": "#/components/schemas/NotepadSpeakerSuggestion"
          speaker_names_changed:
            type: boolean
            description: A speaker was named (or un-named) after the summary was written,
              so the summary and action items still carry the old label. Offer "Update
              notes with these names" — POST /ai_notepad/meetings/{id}/regenerate,
              which skips transcription when a transcript exists. Clears when that
              run starts.
          account_suggestion:
            type: object
            nullable: true
            description: 'The Mango CS customer the automatic linker matched this
              note to, waiting on its owner to confirm (finding #4182 — notes are
              suggested, never linked, because linking makes the note readable by
              every Mango CS user). Confirm with POST /ai_notepad/meetings/{id}/account
              and this `id` as `crm_account_id`; dismiss with POST /ai_notepad/meetings/{id}/account/suggestion/dismiss.
              Null unless the caller may confirm it (Mango CS open to them, the note
              unlinked and not in Recently Deleted, and the caller the note''s creator
              or a direct owner, or a direct editor while “Editors can pass a note
              on” is on).'
            properties:
              id:
                type: integer
              name:
                type: string
    NotepadMeetingDetail:
      allOf:
      - "$ref": "#/components/schemas/NotepadMeetingEntity"
      - type: object
        description: 'What `GET /meetings/{id}` answers: the serializer''s entity
          plus the request-specific affordances it deliberately leaves out.'
        properties:
          note_template:
            type: object
            nullable: true
            description: The note template that shaped the AI notes; null for Auto
              or a note never reshaped. Draw the badge beside Key insights from it
              and tick it in the reshape menu.
            properties:
              id:
                type: integer
              name:
                type: string
          workspace:
            type: object
            nullable: true
            description: The workspace this note is linked to (its action items become
              that workspace's to-dos), or null. `name` is null when the workspace
              has since been moved to Trash, or when it is private and the caller
              cannot open it. Change it with PATCH /ai_notepad/meetings/{id}/workspace.
            properties:
              id:
                type: integer
              name:
                type: string
                nullable: true
              available:
                type: boolean
                description: False once the workspace has been moved to Trash.
              openable:
                type: boolean
                description: True when the caller can open the workspace; link to
                  it only then.
          series:
            type: object
            nullable: true
            description: 'The recurring series this note belongs to (two notes by
              the same person on occurrences of one calendar event, or with the same
              calendar name within 60 days): the previous occurrence''s note and this
              note''s position. Null when there is no earlier note. The "since last
              time" content itself is an ordinary insights section (`since_last_time`,
              first in the artifacts'' `sections`) — nothing to render specially;
              this is the link a client draws "← previous" from.'
            properties:
              previous_meeting:
                type: object
                properties:
                  id:
                    type: integer
                  title:
                    type: string
                  occurred_at:
                    type: string
                    format: date-time
                    nullable: true
              count:
                type: integer
                description: This note's position in the series (1-based).
          recording:
            type: object
            nullable: true
            description: Signed, expiring — refetch rather than storing it.
            properties:
              url:
                type: string
                nullable: true
              expires_in:
                type: integer
                nullable: true
              content_type:
                type: string
                nullable: true
              byte_size:
                type: integer
                nullable: true
          audio_overview:
            "$ref": "#/components/schemas/NotepadAudioOverview"
          document:
            type: object
            nullable: true
            description: The source document, for opening externally.
            properties:
              web_url:
                type: string
                nullable: true
              updated_at:
                type: string
                format: date-time
                nullable: true
          share_link:
            allOf:
            - "$ref": "#/components/schemas/NotepadShareLink"
            nullable: true
            description: Always present; null until a public link exists. Public share
              links are not yet implemented, so this is currently always null.
          meeting:
            allOf:
            - "$ref": "#/components/schemas/NotepadMeetingEntity"
            description: 'The identical serialized entity, repeated under the key
              every OTHER meeting-returning action uses (`{ meeting: … }`), so one
              "render a meeting" client path works against all of them. The bare root
              keys are the shipped contract and stay; this is the forward one. The
              request-specific extras above (`recording`, `audio_overview`, `document`,
              `share_link`) are deliberately NOT folded into it.'
    NotepadMutationAck:
      type: object
      description: |-
        ONE MERGEABLE SHAPE FOR THE FOUR TERSE ACKS — delete, restore, pin and unpin. Every one of them answers all five keys, so a client merges any of them into its cached row with a single code path and re-renders correctly. (This replaces the old NotepadPinState, which documented only `id`/`pinned`/`pinned_at`, and the delete/restore shapes, which documented only `id`/`deleted_at` and the wrong entity respectively.)
        Both pairs matter on every ack: `pinned_at` is the column every viewer's list is ordered by, so a restore that did not report it left the client unable to place the note it just brought back; and `deleted`/`deleted_at` on a pin ack is what tells a client the row it is re-sorting is still live.
      properties:
        id:
          type: integer
        deleted:
          type: boolean
        deleted_at:
          type: string
          format: date-time
          nullable: true
          description: Non-null only while the note is in Recently Deleted; the client
            counts the 30-day purge window down from it.
        pinned:
          type: boolean
        pinned_at:
          type: string
          format: date-time
          nullable: true
        recording_deleted_after_restore:
          type: boolean
          description: Restore only. True when the note's recording came back with
            it but its audio-retention window ends before the next nightly cleanup,
            which then deletes it (the web's restore notice says the same). Recently
            Deleted itself never loses a recording to that cleanup.
    NotepadCreateResponse:
      type: object
      description: ONE BODY SHAPE FOR EVERY OUTCOME OF the create. The action has
        three exits — the 201 that made a note, and the two 200s that hand back a
        note that already existed for the same calendar occurrence — and all three
        answer this, so a client merges any of them into one cache.
      properties:
        meeting:
          "$ref": "#/components/schemas/NotepadMeeting"
        processing:
          type: object
          description: Whether the AI pipeline was actually enqueued, so a client
            can stop rendering a progress state for a job that was never started.
            `awaiting_media` is the OPPOSITE of `nothing_to_process` — an upload meeting
            is created with no media on purpose and the recording enqueues the job
            on its own request, so the progress affordance should stay up.
          properties:
            started:
              type: boolean
              description: True only when `reason` is `queued`.
            reason:
              type: string
              enum:
              - queued
              - ai_disabled
              - auto_process_disabled
              - nothing_to_process
              - awaiting_media
              - already_exists
              - processing_unavailable
        truncated_fields:
          type: array
          description: Which body fields the server's length ceiling cut. Always an
            array so the key set is stable; empty is the normal case.
          items:
            type: string
        calendar:
          type: object
          nullable: true
          description: The calendar identity of the note being handed back, or null
            when it carries none. The dedup lookup deliberately keys on the event
            id and NOT the provider, so a create for one provider can legitimately
            return the note stored under another — `provider_mismatch` is how the
            client learns that rather than mislabelling the note.
          properties:
            event_id:
              type: string
            provider:
              type: string
              nullable: true
            requested_provider:
              type: string
              nullable: true
            provider_mismatch:
              type: boolean
    NotepadNoteTemplate:
      type: object
      description: One of the tenant's note templates, reduced to what a picker shows.
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        system_template:
          type: boolean
          description: Seeded by MangoApps (true) or authored in this workspace (false).
        favorite:
          type: boolean
          description: Whether the caller has starred this template. Favourites are
            listed first.
        usage_count:
          type: integer
        sections:
          type: array
          description: The headings the AI notes take under this template, in order.
          items:
            type: object
            properties:
              title:
                type: string
              type:
                type: string
                description: "`text` or `checklist`."
    NotepadArtifacts:
      type: object
      description: The AI output for a meeting.
      properties:
        meeting_id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - ready
          - failed
          description: The ARTIFACTS vocabulary — `pending` means show a skeleton,
            not an error. Note this key carries the MEETING vocabulary on the sibling
            transcript and action-items reads, and the two COLLIDE on `failed`; `artifacts_status`
            below is the unambiguous name.
        artifacts_status:
          type: string
          enum:
          - pending
          - ready
          - failed
          description: The same value as `status`, restated under the name all three
            of this meeting's reads share.
        meeting_status:
          type: string
          enum:
          - processing
          - completed
          - failed
          description: The meeting's own lifecycle, unambiguously named.
        summary:
          type: string
          nullable: true
        meeting_type:
          type: string
          nullable: true
        sections:
          type: array
          description: Ordered insight sections, as rendered.
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              items:
                type: array
                items:
                  type: string
        decisions:
          type: array
          items:
            type: string
        risks:
          type: array
          items:
            type: string
        themes:
          type: array
          items:
            type: string
        error_message:
          type: string
          nullable: true
        meta:
          type: object
          description: What the four collections above hold, and what was capped off
            them — the only read in the namespace that returned collections with no
            count and no bound.
          properties:
            sections_count:
              type: integer
              description: Sections BEFORE the cap, so a client sees what it is missing.
            items_count:
              type: integer
            decisions_count:
              type: integer
            risks_count:
              type: integer
            themes_count:
              type: integer
            max_sections:
              type: integer
            max_items:
              type: integer
            has_more:
              type: boolean
              description: True when any cap bit. There is no cursor — refetch is
                the only page.
    NotepadSectionSaveResponse:
      type: object
      description: Returned by every section write, so the client can re-render without
        a refetch.
      properties:
        meeting_id:
          type: integer
        section:
          "$ref": "#/components/schemas/NotepadSectionName"
        artifacts:
          "$ref": "#/components/schemas/NotepadArtifacts"
        action_items:
          type: array
          description: Present only for the `action_items` section.
          items:
            "$ref": "#/components/schemas/NotepadActionItem"
        saved_at:
          type: string
          format: date-time
    NotepadSectionVersion:
      type: object
      properties:
        id:
          type: integer
        section:
          "$ref": "#/components/schemas/NotepadSectionName"
        created_at:
          type: string
          format: date-time
        author:
          "$ref": "#/components/schemas/NotepadUserCompact"
        preview:
          type: string
          nullable: true
          description: Truncated content, for choosing a version to restore.
    NotepadTranscriptSegment:
      type: object
      properties:
        id:
          type: integer
        seq:
          type: integer
          description: 1-based ordinal; also the pagination cursor.
        t:
          type: string
          nullable: true
          description: Human time label.
        start_ms:
          type: integer
          nullable: true
          description: Currently always null — segments are derived from the stored
            transcript at request time, with no per-segment timing.
        end_ms:
          type: integer
          nullable: true
          description: Currently always null.
        speaker:
          type: string
          nullable: true
        text:
          type: string
        is_final:
          type: boolean
        confidence:
          type: number
          nullable: true
          description: Currently always null.
        highlights:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true
    NotepadActionItem:
      type: object
      properties:
        id:
          type: integer
        meeting_id:
          type: integer
        title:
          type: string
        done:
          type: boolean
          description: The client view of `status`.
        status:
          type: string
          enum:
          - pending
          - completed
          - cancelled
        commitment_type:
          type: string
          enum:
          - hard
          - soft
        due_date:
          type: string
          format: date
          nullable: true
        due_label:
          type: string
          nullable: true
          description: Server-rendered relative due label ("Overdue", "Tomorrow").
        completed_at:
          type: string
          format: date-time
          nullable: true
        assignee:
          allOf:
          - "$ref": "#/components/schemas/NotepadAssignee"
          nullable: true
        created_at:
          type: string
          format: date-time
        linked_task_state:
          type: string
          nullable: true
          enum:
          - still_open
          - still_done
          -
          description: 'Set when the item was converted into a Task and that Task
            disagrees with the item: `still_open` — the item is ticked or cancelled
            but its Task is still open in Tasks (the Task needs a completion photo
            or notes, or the person who ticked may not complete it); `still_done`
            — the item was un-ticked but its Task is still completed. `null` when
            there is no linked Task or the two agree. Show it beside the tick.'
    NotepadMember:
      type: object
      description: A person a meeting is shared with. The creator appears as an immutable
        synthetic `owner`.
      properties:
        id:
          type: integer
        user_id:
          type: integer
        name:
          type: string
        email:
          type: string
          nullable: true
        initials:
          type: string
        color:
          type: string
        avatar_url:
          type: string
          nullable: true
        role:
          type: string
          enum:
          - owner
          - editor
          - viewer
        is_owner:
          type: boolean
        shared_at:
          type: string
          format: date-time
          nullable: true
    NotepadAudioCitation:
      type: object
      description: A citation on the narrated audio overview — it labels a SECTION
        of the narration script. A DIFFERENT entity from NotepadChatCitation, despite
        riding under the same `citations` field name; discriminate on the endpoint
        you called, never on which keys happen to exist. Every key is always present,
        with an explicit null when the value is missing.
      properties:
        index:
          type: integer
          nullable: true
          description: 0-based section ordinal.
        label:
          type: string
          nullable: true
        source_hint:
          type: string
          nullable: true
    NotepadAudioOverview:
      type: object
      description: Audio-overview state — the whole shape, identical wherever it appears
        (embedded on the meeting detail, and at the root of the dedicated read).
      properties:
        available:
          type: boolean
        failed:
          type: boolean
          description: 'The generation terminally FAILED. A third state the shape
            used to lack, which left a client that POSTed and then polled seeing `available:
            false` forever on a run that would never finish.'
        state:
          type: string
          enum:
          - ready
          - failed
          - none
          description: 'THE ONE STATE FIELD, so a poll has something to poll on —
            the same key and vocabulary the POST answers with (`generating` there).
            There is deliberately no `generating` value on a read: nothing on the
            meeting records that a run is in flight, and inventing one from the job
            queue would make a read depend on GoodJob''s retention. A client polls
            `none` until it becomes `ready` or `failed`.'
        error:
          type: string
          nullable: true
          description: The failure reason. Non-null only when `failed` is true.
        url:
          type: string
          nullable: true
          description: Signed and expiring — refetch rather than storing it.
        expires_in:
          type: integer
          nullable: true
          description: Seconds.
        script:
          type: string
          nullable: true
          description: The narration script, for captions drawn while the MP3 plays.
            Truncated to the same ceiling the synthesizer used, so the captions cannot
            outrun the audio.
        citations:
          type: array
          items:
            "$ref": "#/components/schemas/NotepadAudioCitation"
    NotepadAudioOverviewDetail:
      allOf:
      - "$ref": "#/components/schemas/NotepadAudioOverview"
      - type: object
        properties:
          meeting_id:
            type: integer
          audio_overview:
            allOf:
            - "$ref": "#/components/schemas/NotepadAudioOverview"
            description: The identical object, repeated under the key `meetings#show`
              nests it at. ONE ENTITY, ONE ACCESSOR — this was the only entity read
              in the namespace served at the top level, so a client needed two accessors
              for one object depending on which endpoint it had called. The root keys
              are the shipped contract and stay.
    NotepadChatCitation:
      type: object
      description: A citation on a grounded chat answer — it points at a SOURCE MEETING,
        not into the transcript. `index` is the same N the inline `[Source N]` marker
        in the answer text carries, renumbered so the first cited source is 1. Citations
        are grounded in retrieved content chunks rather than transcript segments,
        so there is no per-segment time label; `position` is the chunk's ordinal within
        its meeting. Every key is always present, with an explicit null when the value
        is missing.
      properties:
        index:
          type: integer
          nullable: true
        meeting_id:
          type: integer
          nullable: true
        meeting_title:
          type: string
          nullable: true
        excerpt:
          type: string
          nullable: true
          description: Up to 200 characters.
        position:
          type: integer
          nullable: true
    NotepadChatMessage:
      type: object
      properties:
        id:
          type: integer
        role:
          type: string
          enum:
          - user
          - assistant
        content:
          type: string
        created_at:
          type: string
          format: date-time
        citations:
          type: array
          items:
            "$ref": "#/components/schemas/NotepadChatCitation"
    NotepadUpcomingMeeting:
      type: object
      description: 'A calendar event starting soon, for the pre-meeting prompt. NOT
        a row of `GET /meetings` even though both live under a `meetings` key: the
        two share exactly one field name (`title`), this one keys the note as `meeting_id`
        where the list uses `id`, and everything else is calendar data the list has
        never carried. `kind` is the tag that tells them apart.'
      properties:
        kind:
          type: string
          enum:
          - upcoming_meeting
          description: The per-element discriminator against a meetings-list row.
        calendar_event_id:
          type: string
          nullable: true
        dismissed:
          type: boolean
          description: True when the person already answered the pop-up for this occurrence
            on the web, the phone or this client (`POST /meetings/upcoming/dismiss`).
            The row stays in the feed — it is still a meeting to start a capture from
            — so skip the pop-up, not the row.
        calendar_provider:
          type: string
          nullable: true
          description: Send this and `calendar_event_id` back on `POST /meetings`
            so the note the desktop makes and the note the web makes for one occurrence
            are the same row.
        title:
          type: string
        starts_at:
          type: string
          format: date-time
          nullable: true
        ends_at:
          type: string
          format: date-time
          nullable: true
        starts_in_seconds:
          type: integer
        join_url:
          type: string
          nullable: true
        location:
          type: string
          nullable: true
        meeting_id:
          type: integer
          nullable: true
          description: The notepad note that already exists for this occurrence, or
            null — the client opens the existing note rather than starting a second
            one.
    NotepadShareLink:
      type: object
      description: A public, tokenised link to a read-only snapshot. NOT yet implemented
        — the field exists so clients bind against a stable shape, and is currently
        always null on the meeting detail.
      properties:
        token:
          type: string
        url:
          type: string
        scope:
          type: string
          enum:
          - summary
          - full
        expires_at:
          type: string
          format: date-time
          nullable: true
        revoked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    NotepadPreferences:
      type: object
      description: EFFECTIVE preferences — the user's stored values resolved against
        the tenant policy.
      properties:
        meeting_detection_enabled:
          type: boolean
        auto_capture:
          type: string
        default_notebook_id:
          type: integer
          nullable: true
        default_language:
          type: string
          nullable: true
        retain_audio_days:
          type: integer
        notify_on_ready:
          type: boolean
        default_template_id:
          type: integer
          nullable: true
        capture_consent_granted:
          type: boolean
        consent_notice_required:
          type: boolean
        meeting_reminders_enabled:
          type: boolean
        meeting_reminder_lead_minutes:
          type: integer
        user_id:
          type: string
        business_id:
          type: string
        policy:
          type: object
          description: The admin-set ceiling these preferences resolve against.
          properties:
            capture_enabled:
              type: boolean
            max_retain_audio_days:
              type: integer
            consent_notice_text:
              type: string
              nullable: true
            admin_owned:
              type: array
              description: Keys a user cannot override.
              items:
                type: string
    AlertUser:
      type: object
      description: Basic sender details (canonical v1 user shape — id / name / avatar_url).
      nullable: true
      required:
      - id
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
    AlertSummary:
      type: object
      description: |
        Alert shape returned by the list endpoint. `acknowledged` /
        `acknowledged_at` / `checkin_status` / `checked_in_at` / `response_pending`
        reflect the CALLER's own response state; `author` is the sender.
      required:
      - id
      - title
      - body
      - alert_type
      - status
      - urgent
      - ack_required
      - safety_check_in_required
      - channels
      - recipients_count
      - author
      - acknowledged
      - response_pending
      - created_at
      properties:
        id:
          type: integer
        title:
          type: string
        body:
          type: string
        alert_type:
          type: string
          description: Display type derived from the flags (response-first).
          enum:
          - urgent
          - acknowledge
          - safety_check_in
        status:
          type: string
          example: sent
        urgent:
          type: boolean
        ack_required:
          type: boolean
        safety_check_in_required:
          type: boolean
        channels:
          type: array
          items:
            type: string
        recipients_count:
          type: integer
        author:
          "$ref": "#/components/schemas/AlertUser"
        acknowledged:
          type: boolean
          description: Whether the calling user has acknowledged this alert.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        checkin_status:
          type: string
          nullable: true
          enum:
          - safe
          - needs_help
          -
          description: The calling user's safety check-in status, if any.
        checked_in_at:
          type: string
          format: date-time
          nullable: true
        checkin_message:
          type: string
          nullable: true
          description: |
            The message the caller entered in the check-in flow (the `needs_help`
            note; null when they marked safe or haven't checked in). Returned by
            GET /alerts/{id} only.
        response_pending:
          type: boolean
          description: True when the alert needs a response the caller hasn't given
            yet.
        sent_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        approval_id:
          type: integer
          nullable: true
          description: |
            The Comms Hub approval request id. Present on `pending_approvals`
            items (pass it to POST /approvals/{id}/approve|reject), on
            `?filter=draft` items that have a request (the latest one — null for a
            plain draft), and on GET /alerts/{id}. Omitted from the other `alerts`
            filters.
        approval_state:
          type: string
          nullable: true
          enum:
          - draft
          - pending_approval
          - approved
          - rejected
          description: |
            The alert's position in the approval pipeline, from its latest Comms
            Hub approval request: `draft` (never submitted / withdrawn),
            `pending_approval` (awaiting a reviewer), `approved` (cleared, pending
            the author's send), or `rejected`. Present on `?filter=draft` items
            and on GET /alerts/{id} for a DRAFT alert. On the detail view it is
            `null` once the alert is published, for a draft that never entered the
            approval pipeline, and for a caller who may not see the alert's
            internal review trail (non-author, non-privileged).
        approval_decided_by:
          type: string
          nullable: true
          description: |
            The NAME of the reviewer who approved or rejected the alert, as it
            reads today — "Deleted user" for a reviewer since erased or gone. On
            `?filter=draft` items and GET /alerts/{id}. Populated only when
            `approval_state` is `approved` or `rejected`; null otherwise (and null
            on the detail view for callers who can't see the review trail).
        approval_decided_at:
          type: string
          format: date-time
          nullable: true
          description: |
            When the alert was approved/rejected (ISO-8601). On `?filter=draft`
            items and GET /alerts/{id}. Null unless `approval_state` is
            `approved`/`rejected`.
        approval_notes:
          type: string
          nullable: true
          description: |
            The reviewer's decision notes for the approval/rejection. On
            `?filter=draft` items and GET /alerts/{id}. Null unless
            `approval_state` is `approved`/`rejected` (and null when the reviewer
            left no note, or for a detail-view caller who can't see the trail).
    AlertInput:
      type: object
      description: Writable alert attributes (create).
      required:
      - title
      - body
      properties:
        title:
          type: string
        body:
          type: string
        sms_body:
          type: string
        action_url:
          type: string
        urgent:
          type: boolean
        ack_required:
          type: boolean
        safety_check_in_required:
          type: boolean
        audience_id:
          type: integer
        channels:
          type: array
          items:
            type: string
    AlertTemplate:
      type: object
      description: |
        A reusable alert composition offered in the composer's template picker.
        `your_templates` items are this business's saved templates;
        `common_scenarios` items are platform-curated system templates
        (`system_template: true`). The composition fields pre-fill the new-alert
        form; the saved default audience lives in `metadata` (empty for system
        scenarios).
      required:
      - id
      - name
      - system_template
      - body
      - channels
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        system_template:
          type: boolean
          description: True for platform-curated common scenarios (business_id is
            null).
        title:
          type: string
          nullable: true
        body:
          type: string
        sms_body:
          type: string
          nullable: true
        channels:
          type: array
          items:
            type: string
        urgent:
          type: boolean
        ack_required:
          type: boolean
        safety_check_in_required:
          type: boolean
        metadata:
          type: object
          description: |
            Raw targeting blob the composer hydrates the new-alert form from —
            holds `extra_user_ids`, `audience_criteria`, and
            `recipient_group_ids`. Empty object for system scenarios.
          additionalProperties: true
        audience:
          type: object
          nullable: true
          description: |
            Resolved delivery-target ("Audience") breakdown — present on the show
            endpoint and on `pending_approvals` / `?filter=draft` items (omitted
            from the paginated received list to stay N+1-free). Every selected
            entity is resolved to `{ id, name }` so the client renders
            human-readable labels instead of bare ids.
          properties:
            id:
              type: integer
              nullable: true
              description: Bound CommsHub audience id (null when none).
            name:
              type: string
              nullable: true
            summary:
              type: string
              nullable: true
            recipient_groups:
              type: array
              description: Selected notification recipient groups, each as { id, name
                }.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                    nullable: true
            specific_user_ids:
              type: array
              description: Ad-hoc specific recipients, each resolved to { id, name
                }.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                    nullable: true
            audience_criteria:
              type: array
              description: |
                Attribute-based targeting, one entry per criterion. Each carries
                its `type` and an `items` array of `{ id, name }`: id-based types
                (`location`, `department`) resolve to the record's name; value-based
                types (`role`, `job_title`) echo the value as both id and name;
                `everyone` (and any unknown type) carries an empty `items`.
              items:
                type: object
                properties:
                  type:
                    type: string
                    enum:
                    - location
                    - department
                    - role
                    - job_title
                    - everyone
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          oneOf:
                          - type: integer
                          - type: string
                        name:
                          type: string
                          nullable: true
    AlertListMeta:
      type: object
      description: |
        Pagination + "needs my response" counts for the list endpoint, spanning
        the whole received inbox (independent of the active `filter`):
          * acknowledge     — ack-required alerts the caller has NOT acknowledged
          * safety_check_in — safety-check-in alerts the caller has NOT responded to
          * all             — the sum of the two (total responses the caller owes)
        No `urgent` count — the accountability invariant makes every alert urgent,
        so it would just equal `all`.
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      - segment_counts
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        segment_counts:
          type: object
          required:
          - all
          - acknowledge
          - safety_check_in
          - draft
          properties:
            all:
              type: integer
            acknowledge:
              type: integer
            safety_check_in:
              type: integer
            draft:
              type: integer
              description: 'The caller''s OWN unsent alerts (authored + status ''draft'')
                — the count for the ?filter=draft tab. Author-scoped, unlike the received-inbox
                counts above.

                '
    AnalyticsRefusal:
      type: object
      required:
      - ok
      - error
      properties:
        ok:
          type: boolean
          example: false
        error:
          type: string
          example: forbidden
    SentimentAlert:
      type: object
      properties:
        id:
          type: integer
        alert_type:
          type: string
          enum:
          - spike
          - confusion
          - policy_resistance
          - culture_concern
        severity:
          type: string
          enum:
          - low
          - medium
          - high
        description:
          type: string
        detected_at:
          type: string
          format: date-time
        segment_id:
          type: integer
          nullable: true
        segment_name:
          type: string
          nullable: true
          description: Always null in the dismiss response.
        contributing_post_count:
          type: integer
        metadata:
          type: object
        status:
          type: string
          example: dismissed
        dismissed_at:
          type: string
          format: date-time
          nullable: true
        dismissed_reason:
          type: string
          nullable: true
    AppointmentSchedulerRefusal:
      type: object
      description: This controller's own refusal envelope.
      required:
      - success
      - message
      properties:
        success:
          type: boolean
          enum:
          - false
        message:
          type: string
        code:
          type: string
          enum:
          - slot_taken
          - not_cancellable
          - not_found
          - invalid
          description: Present on a 422 from cancel or reschedule only.
    AppointmentSchedulerPaginationMeta:
      type: object
      required:
      - total
      - page
      - per_page
      - total_pages
      properties:
        total:
          type: integer
          description: Rows across all pages.
        page:
          type: integer
        per_page:
          type: integer
        total_pages:
          type: integer
    AppointmentSchedulerSlot:
      type: object
      required:
      - starts_at
      - ends_at
      properties:
        starts_at:
          type: string
          format: date-time
          description: UTC, ISO 8601 (`...Z`).
        ends_at:
          type: string
          format: date-time
          description: UTC, ISO 8601 (`...Z`).
    AppointmentSchedulerBookingPage:
      type: object
      required:
      - id
      - title
      - slug
      - public_path
      - active
      - bookable
      - public_booking_disabled
      - unbookable_reason
      - visibility
      - timezone
      - owner
      - relationship
      - meeting_types
      properties:
        id:
          type: integer
        title:
          type: string
        slug:
          type: string
        public_path:
          type: string
          description: "`/meet/<slug>` on the business's own host."
          example: "/meet/sales-demo"
        active:
          type: boolean
        bookable:
          type: boolean
          description: 'The page takes bookings right now: active, not expired, with
            an active meeting type and a host left to serve it (or a live routing
            question) — and the business has public booking switched on.'
        public_booking_disabled:
          type: boolean
          description: The business has **Allow booking via public /meet pages** switched
            off, so every /meet link answers "unavailable".
        unbookable_reason:
          type: string
          nullable: true
          enum:
          - inactive
          - expired
          - no_active_type
          - no_host
          -
          description: Why the page itself can't take bookings; null when it can (a
            page that books only through its routing question reports null).
        visibility:
          type: string
          enum:
          - link_only
          - public
          - org_only
          description: '`link_only` — "Link only — not listed anywhere"; anyone with
            the link can book. `public` — "Listed in the org directory"; anyone with
            the link can book, and a listed SOLO page is what the owner''s profile
            "Book time" button opens. `org_only` — "Listed internally — invitees must
            sign in to book"; listed the same way, but only signed-in members of the
            business can book.'
        timezone:
          type: string
          description: IANA time zone the page's availability is set in.
        owner:
          type: object
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
              description: Null when the owner has no name on file.
        relationship:
          type: string
          enum:
          - owner
          - member
          - admin
          description: "`owner`, `member` (hosts on it as a team member), or `admin`
            (listed only because the caller is an Appointment Scheduler admin)."
        meeting_types:
          type: array
          items:
            type: object
            required:
            - id
            - name
            - duration_minutes
            - active
            - assignment_mode
            - group_seat_count
            properties:
              id:
                type: integer
              name:
                type: string
              duration_minutes:
                type: integer
                minimum: 1
                maximum: 480
              active:
                type: boolean
              assignment_mode:
                type: string
                enum:
                - solo
                - round_robin
                - collective
                - group
              group_seat_count:
                type: integer
                nullable: true
                description: Seats per session for a `group` meeting type; null otherwise.
    AppointmentSchedulerAppointment:
      type: object
      required:
      - id
      - invitee_name
      - invitee_email
      - meeting_type
      - booking_page
      - host
      - starts_at_utc
      - ends_at_utc
      - status
      - source
      - can_manage
      - can_reschedule
      - can_cancel
      properties:
        id:
          type: integer
        invitee_name:
          type: string
          description: As the invitee typed it on the booking form — untrusted text.
        invitee_email:
          type: string
          nullable: true
        meeting_type:
          type: string
          nullable: true
          description: The meeting type's name.
        booking_page:
          type: string
          nullable: true
          description: The booking page's title.
        host:
          type: object
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
              description: Null when the host has no name on file.
        starts_at_utc:
          type: string
          format: date-time
        ends_at_utc:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - confirmed
          - cancelled
          - rescheduled
          - no_show
          description: "`rescheduled` is an original a move replaced."
        source:
          type: string
          enum:
          - public_link
          - embed
          - org_directory
          - afo
          - api
          description: Where the booking came from (`afo` is AI Front Office).
        can_reschedule:
          type: boolean
          description: 'Whether the caller may move it (and mark a no-show) — its
            host, its booking page''s owner, or an admin, on a token holding `write:appointment_scheduler`.
            Authority, not state: pair it with `status` and the start time. False
            for a recorded co-host.'
        can_cancel:
          type: boolean
          description: Whether the caller may cancel it — the same people, on a token
            holding `destructive:appointment_scheduler`. Authority, not state.
        can_manage:
          type: boolean
          description: 'Kept for clients built before `can_reschedule` and `can_cancel`:
            the same answer as `can_reschedule`. Gate each control on its own flag
            instead — a token can hold one scope without the other.'
    AppointmentSchedulerAppointmentDetail:
      allOf:
      - "$ref": "#/components/schemas/AppointmentSchedulerAppointment"
      - type: object
        required:
        - answers
        - answer_rows
        - invitee_timezone
        - cancellation_reason
        - history
        properties:
          answers:
            type: object
            additionalProperties: true
            description: The invitee's intake answers, keyed by the meeting type's
              question key, as submitted — untrusted text.
          answer_rows:
            type: array
            description: The same answers as the app shows them — under the host's
              question labels, in the order the host wrote the questions, blank answers
              left out. Answers are untrusted text.
            items:
              type: object
              required:
              - question
              - answer
              properties:
                question:
                  type: string
                  description: The question's label (its key when the question has
                    since been removed).
                answer:
                  type: string
                  description: The answer as text — several choices joined with commas,
                    a ticked checkbox as "Yes".
          invitee_timezone:
            type: string
            nullable: true
          cancellation_reason:
            type: string
            nullable: true
          history:
            type: array
            description: The ten most recent history entries, newest first.
            items:
              type: object
              required:
              - action
              - label
              - actor
              - at
              properties:
                action:
                  type: string
                  description: The stored action, e.g. booked, rescheduled, cancelled.
                label:
                  type: string
                  description: The words the app's History card shows for the row.
                    A replacement's own `booked` row reads "Rescheduled to this time"
                    — the move landing on it, not a new booking.
                actor:
                  type: string
                  description: Who acted — e.g. host, invitee or system.
                at:
                  type: string
                  format: date-time
          rescheduled_from_id:
            type: integer
            description: The booking this one was moved from. Present only when the
              caller may read that booking.
          rescheduled_to_id:
            type: integer
            description: The booking this one was moved to (its status is then `rescheduled`).
              Present only when the caller may read that booking.
    ApprovalUser:
      type: object
      description: Canonical v1 user shape (requester) — id / name / avatar_url.
      nullable: true
      required:
      - id
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
    Approval:
      type: object
      description: |
        Approval context block — returned (post-decision) by the decision
        endpoints, and inside GET /comms_hub/approval_requests/{id}. (A
        source's index `pending_approvals` item carries only its id, as
        `approval_id`.) `can_act`, `can_reject` and `can_withdraw` are
        what the caller may do now, as the decision doors will answer: the
        person's rule and the token's scope together.
      required:
      - request_id
      - status
      - requested_at
      - current_step
      - can_act
      - can_reject
      - can_withdraw
      properties:
        request_id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - withdrawn
          - overridden
        requested_by:
          "$ref": "#/components/schemas/ApprovalUser"
        requested_at:
          type: string
          format: date-time
        current_step:
          type: object
          required:
          - index
          - total
          properties:
            index:
              type: integer
              description: Zero-based index of the current step.
            total:
              type: integer
              description: Total number of steps in the workflow.
            role:
              type: string
              nullable: true
              description: The UserBusiness role that can act on this step.
            label:
              type: string
              nullable: true
              description: Human label for this step.
        can_act:
          type: boolean
          description: Whether the caller can approve the current step now — never
            their own request (another approver decides it) unless they are a company
            admin, and never on a token without `write:broadcasts`.
        can_reject:
          type: boolean
          description: "`can_act`, and the token carries rejecting's delete-level
            scope (`destructive:broadcasts`; an alert's, `destructive:safety_hub`)."
        can_withdraw:
          type: boolean
          description: 'Whether `POST /approvals/{id}/withdraw` takes it back for
            the caller now: it is pending, the caller asked for it or is a company
            admin (while Approvals is off, only the person who asked), and the token
            carries `write:broadcasts` and the delete-level scope.'
        content_version:
          type: integer
          nullable: true
          description: |
            The item's version as Unix seconds — pass it back as `seen` on
            approve/reject to refuse a decision on a version you didn't read.
    ApprovalDecision:
      type: object
      description: Response from POST /approvals/{id}/approve, /reject and /withdraw.
      required:
      - source_type
      - source_id
      - approval
      properties:
        message:
          type: string
          description: |
            Approve and reject only — what the decision did, in the web queue's
            words: "Approved. The communication is released to send.", a step
            hand-off ("Approved. Passed to step 2 of 3."), "Approved, but it
            won't go out: its author has left the company.", or whether the
            author was told about a rejection.
        source_type:
          type: string
          description: The decided item's source type — a post, a broadcast, an alert
            or a newsletter issue.
          enum:
          - NewsFeed::Feed
          - Broadcast
          - Alert
          - CommsHub::Issue
        source_id:
          type: integer
          description: The source record's id (the post, broadcast, alert or newsletter
            issue).
        approval:
          allOf:
          - "$ref": "#/components/schemas/Approval"
          description: |
            The approval state AFTER the decision — `status` reflects the new
            state (`approved` / `rejected`, or still `pending` if the workflow
            advanced to a later step) and `can_act` is `false`.
    AskAiAttachmentResponse:
      type: object
      properties:
        attachment:
          "$ref": "#/components/schemas/AskAiAttachment"
    AskAiAttachment:
      type: object
      properties:
        signed_id:
          type: string
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        status_url:
          type: string
          description: Present for videos; same-origin relative URL
        processing:
          type: object
          description: Present for videos only. Extracted content is private and excluded.
          properties:
            state:
              type: string
              enum:
              - queued
              - processing
              - ready
              - failed
              - cancelled
              - expired
            message:
              type: string
            duration:
              type: number
            has_audio:
              type: boolean
            frames_done:
              type: integer
              minimum: 0
              maximum: 6
            expires_at:
              type: string
              format: date-time
    Thread:
      type: object
      properties:
        id:
          type: string
          description: The thread id (session id)
        type:
          type: string
          example: general
        status:
          type: string
          enum:
          - active
          - completed
          - archived
        message_count:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        metadata:
          type: object
        title:
          type: string
          nullable: true
          maxLength: 100
          description: The saved name or first question asked; null for an unnamed
            empty thread
        pinned:
          type: boolean
          description: Whether the caller has pinned this private thread across clients
        active:
          type: boolean
          description: The thread the next message lands in
        last_message_at:
          type: string
          format: date-time
          description: When the thread was last talked in — its newest visible message,
            or updated_at for a thread with none. Sent by the conversations list,
            which is ordered by it (pinned first).
    ThreadResponse:
      type: object
      properties:
        success:
          type: boolean
        conversation:
          "$ref": "#/components/schemas/Thread"
    MessageResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        context_app:
          type: object
          nullable: true
          description: |
            Present only when the request sent `app_slug`. The app the turn is
            bound to, or null when the slug was not honoured (unknown, not open
            to this person, or no agent for it). Show an "Asking about …"
            indicator only when this is non-null.
          properties:
            slug:
              type: string
              example: tasks
            name:
              type: string
              example: Tasks
        conversation_id:
          type: string
          format: uuid
          description: Unique identifier for this conversation
          example: 550e8400-e29b-41d4-a716-446655440000
        status:
          type: string
          enum:
          - processing
          - completed
          - error
          example: processing
        websocket:
          type: object
          description: WebSocket subscription details
          properties:
            channel:
              type: string
              example: AiResponseChannel
            subscription:
              type: object
              properties:
                conversation_id:
                  type: string
                  format: uuid
    Conversation:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Conversation session ID
        type:
          type: string
          enum:
          - forms
          - tasks
          - epms
          - general
          example: general
        status:
          type: string
          enum:
          - active
          - completed
          - archived
          example: active
        message_count:
          type: integer
          example: 12
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        metadata:
          type: object
          additionalProperties: true
    Message:
      type: object
      properties:
        role:
          type: string
          enum:
          - user
          - assistant
          - system
          example: assistant
        content:
          type: string
          example: You have 15 PTO days remaining for this year.
          description: |
            The message text. An answer grounded in company knowledge lists its
            sources only in `metadata.provenance.sources` (or, for rows that nest
            it, `metadata.metadata.provenance.sources`) — its text carries no
            "Sources" list, so render that list.
        timestamp:
          type: string
          format: date-time
        ai_generated:
          type: boolean
          example: true
          description: |
            Machine-readable AI-output mark (EU AI Act Art. 50(2)): true on a
            stored assistant answer, false on the person's own words. The web
            transcript's `data-ai-generated` attribute and the live `complete`
            frame say the same.
        metadata:
          type: object
          additionalProperties: true
          description: |
            Allowlisted message metadata (see the Ask AI guide, "Message
            metadata"). The row's own keys may sit one level down under
            `metadata`; read each key at its current path.
          properties:
            provenance:
              "$ref": "#/components/schemas/AnswerProvenance"
            turn_id:
              type: string
              description: Exact answer identity for feedback and correction requests.
            feedback_allowed:
              type: boolean
              description: False when the caller cannot submit feedback, including
                read-only tokens and tool-confirmation outcome rows; hide rating controls
                when false.
            correctable:
              type: boolean
              description: False when this answer cannot accept a knowledge correction.
            answer_feedback:
              type: object
              description: The caller's saved feedback. Staff review fields are never
                included.
              properties:
                rating:
                  type: string
                  enum:
                  - up
                  - down
                reason:
                  type: string
                  enum:
                  - incorrect
                  - missing
                  - off_topic
                  - technical
                comment:
                  type: string
                  maxLength: 2000
            escalated_label:
              type: string
              description: |
                Present on an answer already escalated with "Get human help":
                where it went, in the web's own words ("Sent to support (#123)
                on Oct 12"), in the tenant's language. An escalated answer
                carries no `escalation_offer` — do not offer the button again.
              example: Sent to support (#123) on Oct 12
            escalated_ticket_url:
              type: string
              format: uri
              description: |
                The escalation's Service Desk ticket page (absolute URL), for
                linking `escalated_label` as the web and phone do. Present only
                when the answer went to a ticket and Service Desk opens for
                this person.
              example: https://acme.workforce.mangoapps.com/apps/service-desk/123
    AnswerProvenance:
      type: object
      description: |
        Trust badges and the panels under an answer — the same shape on a
        reloaded message and on the live `complete` frame. Lists are present
        only when non-empty.
      additionalProperties: true
      properties:
        kind:
          type: string
          description: "`computed`, `live_data`, `retrieved`, `inference`, …"
        citation_count:
          type: integer
          description: Documents the answer was grounded in.
        verified_count:
          type: integer
        verified_entry_ids:
          type: array
          maxItems: 50
          items:
            type: integer
        sources:
          type: array
          maxItems: 10
          items:
            "$ref": "#/components/schemas/AnswerSource"
        linked_sources:
          type: array
          maxItems: 10
          description: |
            The records an agent's answer LINKS in its own text — sources a
            knowledge tool retrieved and the answer then linked (Knowledge Layer
            A3). They are already visible inline, so do NOT draw them as a
            second list; they are the answer's sources all the same, with the
            version it used and, on a reloaded answer, `changed_since` — show
            "A source changed since this answer" from either list. A record the
            tool retrieved but the answer never linked is never here.
          items:
            "$ref": "#/components/schemas/AnswerSource"
        answered_for:
          type: object
          description: |
            "Answered for Ohio · hourly" (Knowledge Layer A2) — the asker's own
            profile facts the answer was scoped by, for a client's "Not you?".
            Present only when the answer was scoped to the asker.
          required:
          - label
          - facts
          properties:
            label:
              type: string
              description: The facts in short, joined with " · " (a client prefixes
                "Answered for").
              example: Ohio · hourly
            facts:
              type: array
              maxItems: 6
              description: The profile facts behind `label` ("From your profile").
              items:
                type: object
                properties:
                  label:
                    type: string
                    example: Work location
                  value:
                    type: string
                    example: Store 118, Columbus
            profile_path:
              type: string
              description: A relative same-origin path to the asker's profile overview,
                where a wrong fact is corrected.
    AnswerSource:
      type: object
      description: |
        A record the answer was drawn from. `title` is always present. The
        fields after `excerpt` are present on an answer grounded in company
        knowledge and absent otherwise. Every source has already passed the
        asker's own access check. `title` and `excerpt` are plain text —
        escape them for your medium.
      required:
      - title
      properties:
        title:
          type: string
          maxLength: 200
          example: Paid Time Off Policy
        url:
          type: string
          description: A relative same-origin path — open it against the workspace
            origin. Absent when the record has no page in the workspace; show the
            title as plain text, or link `external_url` when present.
          example: "/apps/policy-hub/policies/42"
        external_url:
          type: string
          format: uri
          maxLength: 400
          description: |
            An absolute `http`/`https` link outside the workspace — a
            knowledge-base article's own source (an external page, a hosted
            video). Never present together with `url`, never another scheme.
            Open it outside the app (system browser or an in-app browser tab),
            never as a workspace path. Absent for every other source.
          example: https://docs.example.com/benefits/remote-work
        excerpt:
          type: string
          maxLength: 200
        marker:
          type: string
          description: The inline citation this source answers to.
          example: "[2]"
        source_type:
          type: string
          description: "`hr_policy`, `sop`, `wiki`, `site_page`, `drive_file`, `knowledge_base_entry`,
            `contract`, `recording`, `ai_note`, …"
          example: hr_policy
        record_id:
          type: integer
          example: 42
        updated_at:
          type: string
          format: date-time
          description: When the document last changed.
        review_overdue:
          type: boolean
          description: The document's scheduled review is overdue — show it.
        owner:
          type: string
          maxLength: 200
          description: |
            Who answers for the document, named as its own page names it: the
            owner, else the owning team, else the author standing in ("Dana Lee
            (author)"). Absent when nobody still here answers for it.
          example: Dana Lee
        reviewed_at:
          type: string
          format: date-time
          description: The date the document's governance clock reads — see `reviewed_kind`.
        reviewed_kind:
          type: string
          enum:
          - reviewed
          - updated
          description: "`reviewed` when `reviewed_at` is a real review (someone confirmed
            it is still right); `updated` when it is only the last publish or edit."
        verified:
          type: boolean
          description: Present (true) for an answer your organization verified. Absent
            otherwise.
        applies_to_you:
          type: boolean
          description: For a policy or SOP narrowed to an audience or a jurisdiction,
            whether it applies to the person asking. Absent when it applies to everyone.
        applies_label:
          type: string
          description: |
            The chip a source that is NOT for the asker carries — its own
            Applies to, worded (Knowledge Layer A2). Present only when
            `applies_to_you` is false.
          example: For California employees
        language:
          type: string
          description: The language the source is written in (a language code).
          example: en
        language_label:
          type: string
          description: '"In English" — present only when the source''s language is
            not the asker''s.'
          example: In English
        trust_line:
          type: array
          description: |
            The one trust line exactly as the web draws it (Knowledge Layer "one
            trust line") — `language_label` when set, then ONE trust word
            ("Review overdue", "Verified", "Reviewed Aug 2026" or "Updated Aug
            2026"), then the owner ("Owned by Dana Lee"). `warning: true` marks
            the word to draw as a warning (review overdue). Render it as given,
            joined with " · "; never re-derive the words. Absent when nothing is
            known.
          items:
            type: object
            required:
            - text
            - warning
            properties:
              text:
                type: string
                example: Reviewed Aug 2026
              warning:
                type: boolean
          example:
          - text: Verified
            warning: false
          - text: Owned by Dana Lee
            warning: false
        version_id:
          type: integer
          description: |
            The published version of the source this answer used — its policy,
            SOP, wiki page or Sites page version (the answer record, Knowledge
            Layer A3). Absent for a source that keeps no versions (a
            knowledge-base entry) or had no published version at the time.
          example: 318
        version_number:
          type: integer
          description: That version's number, as the source's own history numbers
            it ("v4").
          example: 4
        changed_since:
          type: boolean
          description: |
            Present (true) on a reloaded answer — the conversation messages
            endpoint — when the source has published a MATERIAL change since
            this answer used it (its app's own change types decide: a policy's
            major or legal change, a Sites republish with a "What changed?"
            note; a typo fix never counts). Show "A source changed since this
            answer" and offer to ask again. Absent otherwise.
    Pagination:
      type: object
      properties:
        total_count:
          type: integer
          example: 25
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total_pages:
          type: integer
          example: 2
        has_more:
          type: boolean
          example: true
    AgentInfo:
      type: object
      properties:
        name:
          type: string
          example: Scheduling Assistant
        slug:
          type: string
          example: scheduling
        description:
          type: string
          example: Helps with shifts, schedules, and time off requests
        icon:
          type: string
          example: calendar
        examples:
          type: array
          items:
            type: string
          example:
          - What shifts do I have this week?
          - Request time off for next Friday
          - Who is working tomorrow?
    WebSocketEvent:
      type: object
      description: |
        Events broadcast via WebSocket on AiResponseChannel.
        Subscribe with { channel: "AiResponseChannel", conversation_id: "uuid" }
      properties:
        type:
          type: string
          enum:
          - chunk
          - complete
          - error
          - status
          - cancelled
          description: |
            - chunk: Streaming text fragment
            - complete: Full response with metadata
            - error: Error occurred
            - status: Status update (thinking, generating)
            - cancelled: Request was cancelled
        content:
          type: string
          description: Text content (for chunk and complete types)
        message:
          type: string
          description: Status or error message
        metadata:
          type: object
          description: Additional data (for complete type)
          properties:
            conversation_id:
              type: string
            completed_at:
              type: string
              format: date-time
            tokens_generated:
              type: integer
    AskAiSavedAnswer:
      type: object
      required:
      - id
      - saved_at
      - available
      properties:
        id:
          type: integer
          format: int64
          description: Platform bookmark id used for removal and pagination.
        saved_at:
          type: string
          format: date-time
        available:
          type: boolean
          description: Whether this account may still read the answer. Other fields
            are omitted when false.
        conversation_id:
          type: string
          description: Private conversation session id; present only when available.
        turn_id:
          type: string
          description: Durable answer turn id; open the original conversation at this
            turn.
        question:
          type: string
          maxLength: 500
          description: Original visible user question, or nearest preceding visible
            question for legacy transcripts; empty when none survives.
        excerpt:
          type: string
          maxLength: 500
          description: Plain-text preview of the current answer with Markdown syntax
            removed.
    AskAiSettings:
      type: object
      description: Feature settings for the mobile AI client, including current conversation
        for session restoration
      properties:
        success:
          type: boolean
          example: true
        assistant_name:
          type: string
          description: |
            The name the company gave its assistant, for the Ask AI screen and buttons.
            A name the company chose comes back as typed; otherwise the default name,
            translated into the reader's language.
          example: Morris
        assistant_name_custom:
          type: boolean
          description: |
            True when the company renamed its assistant. When false, `assistant_name`
            is the platform default and a client may use its own localized "Ask AI" label.
          example: true
        composer_assist:
          type: object
          description: Supported wire modes and context formats; flags separately
            gate availability.
          properties:
            modes:
              type: array
              items:
                type: string
            formats:
              type: array
              items:
                type: string
                enum:
                - chat_body
            context_scopes:
              type: array
              items:
                type: string
                enum:
                - room
                - thread
        smart_replies:
          type: object
          properties:
            context_scopes:
              type: array
              items:
                type: string
                enum:
                - room
                - thread
        settings:
          type: object
          properties:
            saved_answers_enabled:
              type: boolean
              description: Whether the client may show the saved-answer list.
            save_answers_enabled:
              type: boolean
              description: Whether the caller has write:chat scope to save or remove
                answers.
            create_support_ticket_enabled:
              type: boolean
              description: Whether Ask AI may file Service Desk tickets for this user
                (Service Desk reachable and its chat integration on)
              example: true
            escalation_enabled:
              type: boolean
              description: |
                Whether to offer "Get human help" under an answer whose metadata has
                `escalation_offer: true` and a `turn_id`. The same gate
                `POST /ask_ai/escalate` enforces.
              example: true
            show_create_ticket_button:
              type: boolean
              description: |
                Whether to show a standing "Create Support Ticket" button. Off unless the
                Service Desk admin turned it on — the web chat applies the same rule.
              example: false
            function_calls_enabled:
              type: boolean
              description: Whether AI can execute actions on behalf of the user
              example: true
            schedule_queries_enabled:
              type: boolean
              description: Whether scheduling-related queries are available
              example: true
            general_questions_enabled:
              type: boolean
              description: Whether general Q&A is enabled
              example: true
            clear_conversation_enabled:
              type: boolean
              description: |
                Whether the admin allows users to clear their conversation. Check this
                before showing a "Clear conversation" / "Reset AI" control —
                `DELETE /ask_ai/conversations/{id}` returns 403
                `clear_conversation_disabled` when it is false.
              example: true
            voice_mode_enabled:
              type: boolean
              description: Whether voice mode is available for this business. Check
                this before showing voice button.
              example: true
            voice_mode_limit_info:
              type: object
              nullable: true
              description: Voice mode usage limits (only present if voice_mode_enabled
                is true)
              properties:
                daily_used:
                  type: integer
                  description: Minutes used today
                  example: 5
                daily_limit:
                  type: integer
                  description: Daily limit in minutes
                  example: 30
                remaining:
                  type: integer
                  description: Remaining minutes for today
                  example: 25
                session_limit:
                  type: integer
                  description: Max minutes per session
                  example: 15
        current_conversation:
          nullable: true
          description: |
            The user's current/latest active conversation for session restoration.
            Returns null if no active conversation exists.
            Use this to restore conversation history after logout/login.
          allOf:
          - "$ref": "#/components/schemas/Conversation"
    VoiceErrorResponse:
      type: object
      description: Error response for voice mode requests
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          description: |
            `code` is `payment_required` on the 402 this schema documents;
            `message` is the refusal sentence for display.
          properties:
            code:
              type: string
              example: payment_required
            message:
              type: string
        limit_info:
          type: object
          description: Current usage limits (if applicable)
          properties:
            daily_used:
              type: integer
            daily_limit:
              type: integer
            remaining:
              type: integer
        billing_info:
          type: object
          description: Billing information (if applicable)
          properties:
            balance:
              type: integer
            resource_type:
              type: string
              example: voice_minutes
    VoiceRealtimeChannelEvent:
      type: object
      description: |
        Events broadcast via ActionCable on VoiceRealtimeChannel.
        Subscribe with { channel: "VoiceRealtimeChannel", session_id: "uuid" }

        **Sending Actions:**
        - `process_query`: Send transcript for backend processing
        - `end_session`: Gracefully end the voice session

        **Receiving Events:**
        - `query_response`: Backend AI response ready
        - `query_error`: Query processing failed
        - `session_ended`: Session finalized with billing info
      properties:
        type:
          type: string
          enum:
          - query_response
          - query_error
          - session_ended
          description: Event type
        text:
          type: string
          description: AI response text (for query_response)
        success:
          type: boolean
          description: Whether the query was successful
        error:
          type: string
          description: Error message (for query_error)
        metadata:
          type: object
          description: Additional response metadata
          properties:
            intent:
              type: string
            confidence:
              type: number
        duration_minutes:
          type: number
          description: Final session duration (for session_ended)
        cost_cents:
          type: integer
          description: Final session cost (for session_ended)
        billed:
          type: boolean
          description: Whether billing was processed (for session_ended)
    AskAiSuccess:
      type: object
      properties:
        success:
          type: boolean
          example: true
    AskAiMentionableAgent:
      type: object
      properties:
        alias:
          type: string
          description: What a message starts with to reach this agent (`@<alias> …`).
          example: scheduling
        name:
          type: string
          example: Scheduling Assistant
        description:
          type: string
          description: The agent's tagline, or its description cut to 60 characters;
            may be empty.
        icon:
          type: string
          description: A Font Awesome class.
          example: fa-robot
    AskAiPinnedAgent:
      type: object
      properties:
        label:
          type: string
          description: The agent's name for the chip.
          example: Scheduling Assistant
        domain:
          type: string
          description: The agent's alias.
          example: scheduling
        agent:
          type: string
          description: The agent's internal class name.
    AskAiPrompt:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
          nullable: true
        name:
          type: string
        description:
          type: string
          description: Empty when the prompt has none.
        agent_slug:
          type: string
          nullable: true
          description: The agent the prompt is meant for; null or empty for any.
        category:
          type: string
          nullable: true
        prompt_content:
          type: string
          description: The prompt text.
        variables:
          type: array
          description: The prompt's fill-in variables. Every entry has a non-blank
            `name`; other keys are as the author saved them.
          items:
            type: object
            additionalProperties: true
            properties:
              name:
                type: string
        source:
          type: string
          enum:
          - system
          - tenant
          description: "`system` for the shared gallery, `tenant` for this business's
            own prompts."
        featured:
          type: boolean
    AskAiPromptsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        prompts:
          type: array
          description: System gallery prompts first, then this business's prompts.
          items:
            "$ref": "#/components/schemas/AskAiPrompt"
        truncated:
          type: boolean
          description: True when the gallery or the business's own prompts have more
            than `limit`.
        limit:
          type: integer
          description: The most prompts returned from each of the two lists.
          example: 200
    AskAiUsedPrompt:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        agent_slug:
          type: string
          nullable: true
          description: The agent to send the draft to; null or empty for normal routing.
        prompt_content:
          type: string
          description: The text to put in the composer.
    AskAiMemoryFact:
      type: object
      properties:
        id:
          type: integer
        content:
          type: string
          example: Prefers morning shifts
        fact_type:
          type: string
        category:
          type: string
          nullable: true
        confidence:
          type: number
          minimum: 0
          maximum: 1
        remembered_at:
          type: string
          format: date-time
          description: When the fact was learned.
        last_referenced_at:
          type: string
          format: date-time
          nullable: true
          description: When an answer last used it.
        sample:
          type: boolean
          description: True for a fact loaded as sample data, not learned from the
            person's chats.
    AskAiMemoryResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        memory_enabled:
          type: boolean
          description: |
            Whether personal memory is turned on for this business. Facts already
            remembered are listed either way.
        facts:
          type: array
          items:
            "$ref": "#/components/schemas/AskAiMemoryFact"
        pagination:
          type: object
          properties:
            total_count:
              type: integer
            current_page:
              type: integer
            per_page:
              type: integer
              example: 50
            total_pages:
              type: integer
              description: At least 1, even with no facts.
    AskAiConfirmationRow:
      type: object
      description: One field the action will change, as the confirmation card prints
        it.
      properties:
        label:
          type: string
          description: The field's name, in the person's display language.
        from:
          type: string
          nullable: true
          description: The value now; null when the action creates the record.
        to:
          type: string
          description: The value after the action, as display text ("Not set" when
            empty).
    AskAiPendingConfirmation:
      type: object
      description: |
        An action waiting for approval. Approve or decline it with
        `POST /ask_ai/confirm_tool` and this `id`.
      properties:
        id:
          type: string
          description: The `confirmation_id` to send.
        tool:
          type: string
          nullable: true
          description: The tool the action runs; null for a mix of tools or a playbook
            step.
        summary:
          type: string
          nullable: true
          maxLength: 200
          description: The question as one line of plain text — escape it for your
            medium.
        destructive:
          type: boolean
          description: Draw Confirm as a destructive button and ask for a second click.
        irreversible:
          type: boolean
          description: Say "can't be undone". True only when `destructive` is true.
        fields:
          type: array
          description: What a single-record action changes. Empty when the card shows
            no changes.
          items:
            "$ref": "#/components/schemas/AskAiConfirmationRow"
        records:
          type: array
          description: The records a bulk action changes. Empty when it is not a bulk
            action.
          items:
            type: object
            properties:
              summary:
                type: string
              changes:
                type: array
                items:
                  "$ref": "#/components/schemas/AskAiConfirmationRow"
              invalid:
                type: boolean
                description: True for a record the action cannot apply to.
    AskAiConfirmToolResponse:
      type: object
      properties:
        success:
          type: boolean
          description: |
            Whether the action did what was asked. Can be false on a 200 — the
            confirmation was handled but the action reported a failure, explained
            in `text` (and `error`).
        text:
          type: string
          description: What happened, to show in the conversation (or "Action cancelled
            — no changes were made." on a decline).
        error:
          type: string
          nullable: true
          description: The action's own error, when it reported one.
        ui_response:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            The Ask AI component envelope (`{ components: [{ type, props }] }`)
            for the next approval card or a resumed playbook's output. Its
            components vary by agent.
        follow_ups:
          type: object
          additionalProperties: true
          description: A button row component to show under the result (for example
            "Open import review"), when there is one.
        requires_confirmation:
          type: boolean
          description: Present (true) when the approved step stopped at another approval.
        confirmation_id:
          type: string
          description: The next approval's id, beside `requires_confirmation`.
        confirmation:
          "$ref": "#/components/schemas/AskAiPendingConfirmation"
    AskAiEscalateResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        ticket_id:
          type: integer
          nullable: true
          description: The Service Desk ticket; null when the question went to the
            app's admins.
        routed_to:
          type: string
          enum:
          - service_desk
          - app_admins
        message:
          type: string
          description: The sentence to show, in the person's display language.
    AskAiCableTokenResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        cable_token:
          type: string
          description: Pass as the `cable_token` query parameter when opening the
            WebSocket.
        expires_in:
          type: integer
          description: Seconds the credential lasts.
          example: 3600
        websocket:
          type: object
          properties:
            url:
              type: string
              description: The ActionCable URL for this host.
              example: wss://mycompany.workforce.mangoapps.com/cable
            channel:
              type: string
              example: AiResponseChannel
    AssetProFailure:
      type: object
      required:
      - success
      - message
      properties:
        success:
          type: boolean
          enum:
          - false
        message:
          type: string
    AssetProAssetDetail:
      type: object
      required:
      - id
      - name
      - asset_number
      - serial_number
      - barcode
      - model_number
      - status
      - status_label
      - condition
      - category
      - category_id
      - purchase_date
      - warranty_end_date
      - assigned_to
      - location
      - open_tickets_count
      - last_service_ticket_at
      - asset_kind
      - custom_fields
      - missing_info
      properties:
        id:
          type: integer
        name:
          type: string
        asset_number:
          type: string
          description: The business's own asset number (unique within the business).
        serial_number:
          type: string
          nullable: true
        barcode:
          type: string
          nullable: true
        model_number:
          type: string
          nullable: true
        status:
          type: string
          enum:
          - available
          - assigned
          - pending_assignment
          - maintenance
          - retired
          - lost
          - stolen
        status_label:
          type: string
          description: The status as Asset Pro's screens name it — "Pending Approval"
            for pending_assignment, otherwise the status in words ("Available").
          example: Pending Approval
        condition:
          type: string
          nullable: true
          description: One of excellent, good, fair, poor, damaged or unknown; null
            or an empty string when unset.
          example: good
        category:
          type: string
          nullable: true
          description: The asset category's name.
        category_id:
          type: integer
        purchase_date:
          type: string
          format: date
          nullable: true
        warranty_end_date:
          type: string
          format: date
          nullable: true
        assigned_to:
          type: object
          nullable: true
          description: Who holds the asset; null when it is not assigned.
          required:
          - id
          - name
          - email
          properties:
            id:
              type: integer
            name:
              type: string
              description: The holder's full name; an empty string when no name is
                on file.
            email:
              type: string
              nullable: true
              description: Shown only to the holder, or where the People directory
                shows emails to the caller; null otherwise.
        location:
          type: object
          nullable: true
          description: Where the asset is kept; null when no location is set.
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
        open_tickets_count:
          type: integer
          description: Open Service Desk tickets on the asset, as last counted when
            one of its tickets changed; 0 when none.
        last_service_ticket_at:
          type: string
          format: date-time
          nullable: true
          description: When the most recent Service Desk ticket about the asset was
            submitted; null when it has none.
        asset_kind:
          type: string
          enum:
          - hardware
          - software
        manufacturer:
          type: string
          nullable: true
          description: Left out when the business has switched off the Asset Details
            section.
        department:
          type: string
          nullable: true
          description: The name of the department the asset belongs to. Left out when
            the business has switched off the Asset Details section.
        notes:
          type: string
          nullable: true
          description: Left out when the business has switched off the Asset Details
            section.
        custom_fields:
          type: array
          description: The extra fields an admin added to the asset form (Asset Pro
            › Settings › Asset Form) that apply to this asset, in the admin's order.
            Every applicable field is listed, with a null value when it is empty,
            so every asset of a kind has the same shape.
          items:
            type: object
            required:
            - key
            - label
            - type
            - value
            properties:
              key:
                type: string
                description: The field's fixed identifier. It never changes, even
                  when an admin renames the field.
                example: imei
              label:
                type: string
                description: The field's name as people see it; may change.
                example: IMEI
              type:
                type: string
                enum:
                - text
                - long_text
                - number
                - date
                - dropdown
                - yes_no
              value:
                description: A number for number fields, an ISO 8601 date string (YYYY-MM-DD)
                  for date fields, true or false for yes_no fields, and a string otherwise;
                  null when empty. A value device sync stored that is not valid for
                  the field's type comes back as the string stored.
                oneOf:
                - type: string
                  nullable: true
                - type: number
                  nullable: true
                - type: boolean
                  nullable: true
        missing_info:
          type: array
          description: 'Keys of the required fields that are still empty on this asset:
            standard fields made required in Settings › Asset Form (for example `barcode`,
            `department`) first, then required extra fields by their key. A to-do
            for the people who manage assets (the asset page''s "Missing info"): empty
            for a caller who can only view or hold the asset.'
          items:
            type: string
    Connection:
      type: object
      properties:
        kind:
          type: string
          enum:
          - oauth
          - token
          - office_addin
          description: oauth — an MCP client the caller signed in to; token — a personal
            API token; office_addin — an Office add-in sign-in (its id is the sign-in's
            refresh token).
        id:
          type: integer
        name:
          type: string
          example: Claude
        detail:
          type: string
          nullable: true
          example: Signed in from Word
          description: A line under the name where the kind has one — for an Office
            add-in sign-in, the Office app it signed in from. Null otherwise.
        client_uri:
          type: string
          nullable: true
          example: https://claude.ai
        connected_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
        last_activity_at:
          type: string
          format: date-time
          nullable: true
          description: The latest assistant tool call made through this connection.
        is_current:
          type: boolean
          description: True for the token this request authenticated with.
        scopes:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                example: read:my_data
              description:
                type: string
    BookingsPaginationMeta:
      type: object
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      - has_next_page
      - has_prev_page
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    BookingsSpaceRef:
      type: object
      description: The booked space, as a list row names it.
      required:
      - id
      - name
      - space_type
      properties:
        id:
          type: integer
        name:
          type: string
        space_type:
          type: string
          enum:
          - desk
          - meeting_room
          - parking_spot
          - locker
          - phone_booth
          - av_equipment
          - company_vehicle
          - gym_equipment
    BookingsHolder:
      type: object
      description: The person the booking is held by.
      required:
      - id
      - full_name
      properties:
        id:
          type: integer
        full_name:
          type: string
          description: The holder's display name; may be empty.
    BookingsBookingListItem:
      type: object
      required:
      - id
      - start_time
      - end_time
      - status
      - purpose
      - attendee_count
      - booking_reference
      - checked_in
      - checked_out_at
      - space
      - user
      properties:
        id:
          type: integer
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - confirmed
          - cancelled
          - completed
          - no_show
        purpose:
          type: string
          nullable: true
        attendee_count:
          type: integer
          nullable: true
        booking_reference:
          type: string
          nullable: true
        checked_in:
          type: boolean
          description: True once someone checked in; stays true after check-out.
        checked_out_at:
          type: string
          format: date-time
          nullable: true
        space:
          "$ref": "#/components/schemas/BookingsSpaceRef"
        user:
          "$ref": "#/components/schemas/BookingsHolder"
    BookingsBookingDetail:
      type: object
      required:
      - id
      - start_time
      - end_time
      - status
      - purpose
      - notes
      - attendee_count
      - booking_reference
      - checked_in
      - checked_in_at
      - checked_out_at
      - cost
      - space
      - user
      properties:
        id:
          type: integer
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - confirmed
          - cancelled
          - completed
          - no_show
        purpose:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        attendee_count:
          type: integer
          nullable: true
        booking_reference:
          type: string
          nullable: true
        checked_in:
          type: boolean
          description: True once someone checked in; stays true after check-out.
        checked_in_at:
          type: string
          format: date-time
          nullable: true
        checked_out_at:
          type: string
          format: date-time
          nullable: true
        cost:
          type: string
          nullable: true
          description: A decimal amount rendered as a string (e.g. `"0.0"`).
          example: '0.0'
        space:
          type: object
          required:
          - id
          - name
          - space_type
          - location
          properties:
            id:
              type: integer
            name:
              type: string
            space_type:
              type: string
              enum:
              - desk
              - meeting_room
              - parking_spot
              - locker
              - phone_booth
              - av_equipment
              - company_vehicle
              - gym_equipment
            location:
              type: string
              nullable: true
              description: The space's free-text location.
        user:
          "$ref": "#/components/schemas/BookingsHolder"
    BookingsBookingCreated:
      type: object
      description: The booking as a create or a cancel answers it.
      required:
      - id
      - start_time
      - end_time
      - status
      - booking_reference
      properties:
        id:
          type: integer
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - confirmed
          - cancelled
          - completed
          - no_show
          description: "`confirmed` after a create, `cancelled` after a cancel."
        booking_reference:
          type: string
          nullable: true
    BookingsBookingUpdated:
      type: object
      description: The booking as an update answers it.
      required:
      - id
      - start_time
      - end_time
      - status
      - purpose
      properties:
        id:
          type: integer
        start_time:
          type: string
          format: date-time
        end_time:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - confirmed
          - cancelled
          - completed
          - no_show
        purpose:
          type: string
          nullable: true
    BookingsBookingWrite:
      type: object
      required:
      - booking
      properties:
        booking:
          type: object
          description: |
            The fields to set. On create, `space_id`, `start_time` and
            `end_time` are needed for a valid booking; on update send only
            what changes. Any other key is ignored.
          properties:
            space_id:
              type: integer
              description: One of this business's spaces. An id from another business
                is dropped (the save then fails "Space can't be blank").
            start_time:
              type: string
              description: ISO 8601. A naive time (`2026-10-14T14:00`) is read on
                the room's clock (or in `time_entry_zone`); a time with an offset
                is taken as sent. An unparseable value is left to the model, which
                refuses it.
              example: 2026-10-14T14:00
            end_time:
              type: string
              description: As `start_time`; must be after it.
              example: 2026-10-14T15:00
            purpose:
              type: string
              maxLength: 255
            notes:
              type: string
              maxLength: 2000
            attendee_count:
              type: integer
              minimum: 1
              description: At least 1, and no more than the space's capacity.
            attendees:
              description: Additional attendee names — an array of strings, or one
                string with a name per line. Up to 100 names of up to 255 characters.
              oneOf:
              - type: array
                items:
                  type: string
              - type: string
        time_entry_zone:
          type: string
          description: Optional. The time zone a naive `start_time` / `end_time` is
            read in (a Rails or IANA zone name, e.g. `America/New_York`). An unknown
            name is ignored and the room's clock is used.
    BookingsActionResult:
      type: object
      required:
      - success
      - message
      properties:
        success:
          type: boolean
          enum:
          - true
        message:
          type: string
    BookingsValidationError:
      description: |
        The api/v1 error envelope. For `validation_failed`, `message` joins the
        reasons into one sentence and `details.messages` lists each one; the
        other codes carry no `details`.
      allOf:
      - "$ref": "#/components/schemas/Error"
      - type: object
        properties:
          error:
            type: object
            properties:
              details:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      type: string
                    example:
                    - Space is not available during the selected time
    BookingsSpaceListItem:
      type: object
      required:
      - id
      - name
      - space_type
      - capacity
      - location
      - building
      - floor
      - active
      - bookable
      - hourly_rate
      properties:
        id:
          type: integer
        name:
          type: string
        space_type:
          type: string
          enum:
          - desk
          - meeting_room
          - parking_spot
          - locker
          - phone_booth
          - av_equipment
          - company_vehicle
          - gym_equipment
        capacity:
          type: integer
          nullable: true
        location:
          type: string
          nullable: true
          description: The space's free-text location.
        building:
          type: string
          nullable: true
        floor:
          type: string
          nullable: true
        active:
          type: boolean
        bookable:
          type: boolean
        hourly_rate:
          type: string
          nullable: true
          description: A decimal amount rendered as a string (e.g. `"0.0"`).
          example: '0.0'
    BookingsSpace:
      type: object
      required:
      - id
      - name
      - space_type
      - capacity
      - location
      - building
      - floor
      - description
      - location_id
      - active
      - bookable
      - hourly_rate
      - amenities
      - equipment
      properties:
        id:
          type: integer
        name:
          type: string
        space_type:
          type: string
          enum:
          - desk
          - meeting_room
          - parking_spot
          - locker
          - phone_booth
          - av_equipment
          - company_vehicle
          - gym_equipment
        capacity:
          type: integer
          nullable: true
        location:
          type: string
          nullable: true
          description: The space's free-text location.
        building:
          type: string
          nullable: true
        floor:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        location_id:
          type: integer
          nullable: true
          description: The business location (place) the space belongs to, when set.
        active:
          type: boolean
        bookable:
          type: boolean
        hourly_rate:
          type: string
          nullable: true
          description: A decimal amount rendered as a string (e.g. `"0.0"`).
          example: '0.0'
        amenities:
          type: array
          description: Free-form amenity entries (typically strings).
          items: {}
        equipment:
          type: array
          description: Free-form equipment entries (typically strings).
          items: {}
    BrandingTheme:
      type: object
      required:
      - theme
      - business_id
      properties:
        theme:
          type: object
          required:
          - tokens
          - branding
          - brand_fonts
          - palette
          properties:
            tokens:
              type: object
              description: Every theme token, defaults overlaid with the tenant's
                saved values. Additive over time — treat unknown keys as forward compatibility.
              additionalProperties: true
              properties:
                brand_primary:
                  type: string
                  example: "#3066b3"
                font_heading:
                  type: string
                font_body:
                  type: string
                font_size_base:
                  type: string
                  example: 15px
                text_primary:
                  type: string
                text_muted:
                  type: string
                heading_color:
                  type: string
                page_bg:
                  type: string
                card_bg:
                  type: string
                modal_bg:
                  type: string
                navbar_bg:
                  type: string
                navbar_text:
                  type: string
                  description: Derived from `navbar_bg` when the admin left it unset.
                sidebar_text:
                  type: string
                sidebar_rail_bg:
                  type: string
                sidebar_rail_active_bg:
                  type: string
                border_color:
                  type: string
                border_radius:
                  type: string
                shadow_color:
                  type: string
                style_family:
                  type: string
                  description: The style family, which owns corner radius, card style
                    and density.
            branding:
              type: object
              properties:
                logo_url:
                  type: string
                  nullable: true
                favicon_url:
                  type: string
                  nullable: true
                sidebar_logo_bg_color:
                  type: string
                  nullable: true
            custom_css:
              type: string
              nullable: true
              description: The tenant's raw custom stylesheet, kept apart from the
                tokens because it is not a CSS variable.
            brand_fonts:
              type: array
              description: The uploaded brand fonts the font tokens name, with their
                files' URLs on this origin. Empty when the tokens name none.
              items:
                type: object
                properties:
                  css_family:
                    type: string
                  name:
                    type: string
                  category:
                    type: string
                  faces:
                    type: array
                    items:
                      type: object
                      properties:
                        url:
                          type: string
                        weight:
                          type: integer
                          example: 400
                        style:
                          type: string
                          enum:
                          - normal
                          - italic
                        format:
                          type: string
            palette:
              type: array
              description: Every colour the company's colour choosers offer — brand,
                accent, the colours it added and the ten named ones — each with the
                fill and the text colour that reads on it, and the colour as a mark
                on a card in the light and dark themes. A record that stores a colour
                stores its `key`.
              items:
                type: object
                properties:
                  key:
                    type: string
                  name:
                    type: string
                  group:
                    type: string
                  color:
                    type: string
                  fill:
                    type: string
                  ink:
                    type: string
                  mark:
                    type: string
                  mark_dark:
                    type: string
        business_id:
          type: integer
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: When the theme (or, when it was never saved, the business)
            last changed.
    BroadcastUser:
      type: object
      description: Basic sender details (canonical v1 user shape — id / name / avatar_url).
      nullable: true
      required:
      - id
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        avatar_url:
          type: string
          nullable: true
    BroadcastNamesHidden:
      type: object
      description: |
        Present only when a works council hides names (General Settings ›
        Countries; Knowledge Layer X3): the people this list leaves out —
        counted, never named. Absent when nobody is hidden.

        While the audience's hidden group is smaller than the company's
        smallest reportable group (Admin › Compliance › Retention & Legal
        Holds), how many of them a list of ONE status leaves out would say
        what those few people did: `count` and `group_count` are then null,
        `fewer_than` is that smallest group ("a few"), and the report's
        status counts (views, acknowledgements) count only the named people.

        Below that smallest group no count is given at all (#4546): when the
        hidden group — or the people this one list leaves out — are fewer than
        it, `count` and `group_count` are null and `fewer_than` is the
        smallest group ("a few").
        The same shape is used by every read and acknowledgement list of the
        API (feed viewers and acknowledgements, feed analytics, wiki viewers,
        chat read reports, the team outstanding view).
      required:
      - reason
      properties:
        count:
          type: integer
          nullable: true
          description: How many people this list leaves out; null while `fewer_than`
            is set.
        group_count:
          type: integer
          nullable: true
          description: |
            Null since #4546: a hidden group under the company's smallest group
            is "a few" (`fewer_than`). Kept for older clients; never broken down.
        fewer_than:
          type: integer
          description: |
            Present only when `count` (and `group_count`) are null because the
            people left out are fewer than the company's smallest group: that
            number. Show "a few".
        reason:
          type: string
          example: Names are hidden for people in Germany (works council).
    PlacementEndResult:
      type: object
      description: The answer of an "End now" (`…/placements/{placement_id}/end_now`).
      properties:
        ended:
          type: boolean
          example: true
        placement_id:
          type: integer
        next_placement_id:
          type: integer
          nullable: true
          description: The booking queued behind it that starts now (queued places
            such as Top of Home only), else null.
        message:
          type: string
          example: Ended — it's off Top of Home now. “Holiday hours” starts there
            now.
    BroadcastSummary:
      type: object
      description: |
        Broadcast shape returned by the list endpoint. `viewed` / `acknowledged`
        reflect the CALLER's own state for this broadcast; `created_by` is the
        sender.

        A published broadcast reads in the recipient's language when theirs
        differs (never for the author): `title` / `description` are then the
        translation, `translated` is true and `original_title` /
        `original_description` carry what was written.
      required:
      - id
      - title
      - description
      - status
      - is_critical
      - require_acknowledgment
      - viewed
      - acknowledged
      - can_acknowledge
      - created_by
      - created_at
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
          description: |
            On a translation, an unchecked machine translation ends with a short
            "Machine translation." note in the reader's language; a checked one
            carries no note.
        translated:
          type: boolean
          description: Whether `title` / `description` are a translation for the caller.
        translation_status:
          type: string
          nullable: true
          enum:
          - checked
          - ai_draft
          -
          description: |
            The one reader label's facts (Knowledge Layer P6): `checked` — a
            person checked the translation ("Translated from English · Checked
            by …", `translation_checked_by` names them); `ai_draft` — AI
            translated it and nobody has checked it yet ("Translated by AI — not
            yet checked"). Null when the caller reads the original.
        translation_checked_by:
          type: string
          nullable: true
          description: Who checked the translation — only when `translation_status`
            is `checked`.
        original_title:
          type: string
          nullable: true
          description: The title as written — only when `translated`.
        original_description:
          type: string
          nullable: true
          description: The description as written — only when `translated`.
        status:
          type: string
          example: published
        is_critical:
          type: boolean
        require_acknowledgment:
          type: boolean
        scheduled_at:
          type: string
          format: date-time
          nullable: true
          description: When a "send later" broadcast will go out. Non-null only while
            `status` is `scheduled`.
        delivery_mode:
          type: string
          enum:
          - immediate
          - on_shift_only
          - next_shift_start
          description: The shift-aware delivery window this broadcast was composed
            with. Always present; `immediate` when none was chosen.
        auto_widen_channels:
          type: boolean
          description: Whether each unacknowledged reminder widens the delivery channel
            (email -> +SMS -> +voice). Always present; `false` when off.
        publish_to_signage:
          type: boolean
          description: Whether this broadcast is marked for the break-room screen
            rotation. Always present; `false` when off. A marking, not a delivery
            receipt — each screen still shows it only if its own Communications content
            rule is on, and only while the broadcast is published.
        signage_location_ids:
          type: array
          description: Sites the screen marking is narrowed to. Empty means every
            screen (and is always empty when `publish_to_signage` is false).
          items:
            type: integer
        viewed:
          type: boolean
          description: Whether the calling user has viewed this broadcast.
        acknowledged:
          type: boolean
          description: Whether the calling user has acknowledged this broadcast.
        can_acknowledge:
          type: boolean
          description: 'Whether POST /broadcasts/{id}/acknowledge would succeed for
            the caller now: the broadcast is published, requires acknowledgment, was
            SENT to the caller, and the caller has not acknowledged it yet. Key the
            Acknowledge button on this — never on `require_acknowledgment && !acknowledged`:
            this list also holds broadcasts the caller authored, and the acknowledge
            endpoint answers 403 `not_a_recipient` to anyone the broadcast was not
            sent to.'
        you_owe_acknowledgement:
          type: boolean
          description: 'The same answer as `can_acknowledge`, named for the reader:
            the caller still owes this broadcast an acknowledgement. The web Broadcasts
            list draws a "You haven''t acknowledged this" badge on such a row.'
        acknowledgement:
          type: object
          description: What the web list draws about acknowledging, for the caller.
            `badge` and `action` are its words, set only when `you_owe` is true. The
            action OPENS the broadcast — acknowledging happens on the detail screen,
            after reading — never a POST from a list row.
          properties:
            required:
              type: boolean
              description: The broadcast asks its recipients to acknowledge it.
            you_owe:
              type: boolean
              description: Same as `you_owe_acknowledgement`.
            badge:
              type: string
              nullable: true
              example: You haven't acknowledged this
            action:
              type: string
              nullable: true
              example: Read and acknowledge
        created_by:
          "$ref": "#/components/schemas/BroadcastUser"
        ai_assisted:
          type: boolean
          description: 'Draw the "AI-assisted" label (Knowledge Layer E6). Resolved
            for the caller: true only when the broadcast was written with AI help
            and the company shows that to readers (Communications settings), or the
            caller is its sender, the delegate who sent it or a manager of it.'
        sent_by:
          allOf:
          - "$ref": "#/components/schemas/BroadcastUser"
          nullable: true
          description: The delegate who sent this broadcast as `created_by` ("Sent
            by … on … behalf"), under the same caller-resolved rule; null when there
            is none or the caller may not be told.
        recipients_count:
          type: integer
          description: |
            Projected recipient count (resolved audience). Present on
            `pending_approvals` items so an approver sees the reach; omitted from
            the main `broadcasts` list.
        approval_id:
          type: integer
          description: |
            The Comms Hub approval request id — present ONLY on
            `pending_approvals` items; pass it to POST /approvals/{id}/approve|
            reject. Omitted from the main `broadcasts` list.
        published_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    BroadcastDetail:
      type: object
      description: Full broadcast shape returned by show / create / update / publish.
      required:
      - id
      - title
      - status
      - is_critical
      - created_at
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
        is_critical:
          type: boolean
        published_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        description:
          type: string
        translated:
          type: boolean
          description: Same as `BroadcastSummary.translated`.
        translation_status:
          type: string
          nullable: true
          enum:
          - checked
          - ai_draft
          -
          description: Same as `BroadcastSummary.translation_status` (Knowledge Layer
            P6).
        translation_checked_by:
          type: string
          nullable: true
        original_title:
          type: string
          nullable: true
        original_description:
          type: string
          nullable: true
        require_acknowledgment:
          type: boolean
        scheduled_at:
          type: string
          format: date-time
          nullable: true
          description: When a "send later" broadcast will go out. Non-null only while
            `status` is `scheduled`.
        delivery_mode:
          type: string
          enum:
          - immediate
          - on_shift_only
          - next_shift_start
          description: The shift-aware delivery window this broadcast was composed
            with. Always present; `immediate` when none was chosen.
        auto_widen_channels:
          type: boolean
          description: Whether each unacknowledged reminder widens the delivery channel
            (email -> +SMS -> +voice). Always present; `false` when off.
        publish_to_signage:
          type: boolean
          description: Whether this broadcast is marked for the break-room screen
            rotation. Always present; `false` when off. A marking, not a delivery
            receipt — each screen still shows it only if its own Communications content
            rule is on, and only while the broadcast is published.
        signage_location_ids:
          type: array
          description: Sites the screen marking is narrowed to. Empty means every
            screen (and is always empty when `publish_to_signage` is false).
          items:
            type: integer
        viewed:
          type: boolean
          description: Whether the calling user has viewed this broadcast.
        acknowledged:
          type: boolean
          description: Whether the calling user has acknowledged this broadcast. On
            GET /broadcasts/{id} this is always true when the caller AUTHORED the
            broadcast and is not in its audience — there is nothing for the author
            to acknowledge; otherwise it reflects the caller's own acknowledgment
            (an author who IS a recipient must acknowledge like anyone else).
        can_acknowledge:
          type: boolean
          description: 'Whether POST /broadcasts/{id}/acknowledge would succeed for
            the caller now: published, acknowledgment-required, SENT to the caller,
            and not yet acknowledged by them. Key the Acknowledge button on this —
            the endpoint answers 403 `not_a_recipient` to an author, admin or manager
            who can open the broadcast but was never sent it.'
        you_owe_acknowledgement:
          type: boolean
          description: 'The same answer as `can_acknowledge`, named for the reader:
            the caller still owes this broadcast an acknowledgement. The web Broadcasts
            list draws a "You haven''t acknowledged this" badge on such a row.'
        acknowledgement:
          type: object
          description: What the web list draws about acknowledging, for the caller.
            `badge` and `action` are its words, set only when `you_owe` is true. The
            action OPENS the broadcast — acknowledging happens on the detail screen,
            after reading — never a POST from a list row.
          properties:
            required:
              type: boolean
              description: The broadcast asks its recipients to acknowledge it.
            you_owe:
              type: boolean
              description: Same as `you_owe_acknowledgement`.
            badge:
              type: string
              nullable: true
              example: You haven't acknowledged this
            action:
              type: string
              nullable: true
              example: Read and acknowledge
        created_by:
          "$ref": "#/components/schemas/BroadcastUser"
        ai_assisted:
          type: boolean
          description: 'Draw the "AI-assisted" label (Knowledge Layer E6). Resolved
            for the caller: true only when the broadcast was written with AI help
            and the company shows that to readers (Communications settings), or the
            caller is its sender, the delegate who sent it or a manager of it.'
        sent_by:
          allOf:
          - "$ref": "#/components/schemas/BroadcastUser"
          nullable: true
          description: The delegate who sent this broadcast as `created_by` ("Sent
            by … on … behalf"), under the same caller-resolved rule; null when there
            is none or the caller may not be told.
        can_manage:
          type: boolean
          description: Whether the caller can manage this broadcast (resend / remind
            / archive / edit / stats).
        can_view_tracking:
          type: boolean
          description: 'Whether the caller may open the per-recipient tracking details
            (viewers / acknowledgement response-tracking roster) — the mobile equivalent
            of the web "View stats" page. Matches the gate on GET /broadcasts/{id}/acknowledgements
            (can-manage, not published-gated), so the client shows the affordance
            only when that call will succeed. Returned by GET /broadcasts/{id} only.

            '
        view_count:
          type: integer
          description: |
            Raw view tally (column). Returned by the compose responses
            (create / update / publish) and archive only — NOT by
            GET /broadcasts/{id}, whose detail screen uses `unique_view_count`
            instead. On a published broadcast whose works-council group is
            under the company's smallest group, the named viewers only (see
            `names_hidden`).
        created_by_id:
          type: integer
          description: |
            Author id. Returned by the compose responses only — NOT by
            GET /broadcasts/{id} (the author id is already in `created_by.id`).
        campaign:
          nullable: true
          allOf:
          - type: object
            description: A Communications campaign as a composer draws it (the Campaign
              field).
            required:
            - id
            - name
            - status
            - kickoff_at
            - ended_at
            - open
            - can_change
            properties:
              id:
                type: integer
              name:
                type: string
              status:
                type: string
                enum:
                - draft
                - active
                - completed
                - canceled
                description: Follows the campaign's dates (a Draft turns Active on
                  its kickoff date, an Active one Completed the day after it ends),
                  unless a person set it.
              kickoff_at:
                type: string
                format: date-time
                nullable: true
              ended_at:
                type: string
                format: date-time
                nullable: true
              open:
                type: boolean
                description: Draft or Active — it can take new messages.
              can_change:
                type: boolean
                description: 'Whether the caller may take a message out of it (false
                  for a token without `write:broadcasts`). False: show its name instead
                  of a choice, as the web does.'
          description: |
            The Communications campaign the broadcast is in — for its author or
            a caller who may change that campaign (otherwise `null`, as for one
            in no campaign); `can_change` is false for a token without
            `write:broadcasts`. Returned by GET /broadcasts/{id} and the
            compose responses.
        attachments:
          type: array
          description: Broadcast file attachments (DriveItem media).
          items:
            "$ref": "#/components/schemas/BroadcastAttachment"
        hero_image:
          type: object
          nullable: true
          description: |
            The broadcast's cover picture — the one the web draws above the
            message. Null when it has none. `url` is signed for one hour;
            refetch the broadcast when it lapses. `width` / `height` are the
            picture's own pixels (null until it has been analysed) — draw it
            at that shape, never cropped.
          properties:
            url:
              type: string
            alt:
              type: string
              description: The broadcast's title, as the web uses it.
            width:
              type: integer
              nullable: true
            height:
              type: integer
              nullable: true
        reactions:
          type: array
          description: Reaction tallies on this broadcast, one entry per emoji.
          items:
            type: object
            required:
            - emoji
            - count
            properties:
              emoji:
                type: string
              count:
                type: integer
        has_reacted:
          type: boolean
          description: Whether the CALLING user has reacted (any emoji) on this broadcast.
            Returned by GET /broadcasts/{id} only.
        my_reactions:
          type: array
          description: |
            The emoji(s) the CALLING user has reacted with on this broadcast
            (empty array when none). Same shape as the `my_reactions` field on
            POST /broadcasts/{id}/reactions/toggle, so the detail screen can
            restore the user's selection on load. Returned by GET /broadcasts/{id} only.
          items:
            type: string
            example: "\U0001F44D"
        comments_count:
          type: integer
          description: Number of (non-deleted) comments on this broadcast.
        unique_view_count:
          type: integer
          description: |
            Distinct users who have viewed this broadcast — only the named
            viewers while `names_hidden` is present (the audience's
            works-council group is under the company's smallest group).
        names_hidden:
          allOf:
          - "$ref": "#/components/schemas/BroadcastNamesHidden"
          description: |
            GET /broadcasts/{id} only, and only while the audience's
            works-council group is under the company's smallest group
            (Knowledge Layer X3): `count` and `group_count` null, `fewer_than` set. The
            view and acknowledgement counts on this node then count only the
            named people, as the viewers and acknowledgements lists do.
        recent_comments:
          type: array
          description: The last 5 top-level comments, newest first.
          items:
            "$ref": "#/components/schemas/BroadcastComment"
        can_archive:
          type: boolean
          description: |
            Whether the caller can archive this broadcast. Mirrors the web archive
            guard exactly — true only for a PUBLISHED broadcast the caller can
            manage (admin/manager, or the author when a manager+). Returned by
            GET /broadcasts/{id} only.
        allow_comments:
          type: boolean
          description: Whether commenting is enabled on this broadcast. An archived
            broadcast keeps its thread with comments closed — key the composer on
            `can_comment`. Returned by GET /broadcasts/{id} only.
        can_comment:
          type: boolean
          description: Whether the caller may write a comment or reply now — published,
            commenting enabled, and the `write:broadcasts` scope (what POST /broadcasts/{id}/comments
            answers). Returned by GET /broadcasts/{id} only.
        allow_reactions:
          type: boolean
          description: Whether reacting is enabled on this broadcast (drives the reaction
            bar). Returned by GET /broadcasts/{id} only.
        total_recipients:
          type: integer
          description: Recipients captured in the publish-time snapshot. Only when
            the caller can view stats.
        acknowledged_count:
          type: integer
          description: |
            How many recipients have acknowledged. Only when the caller can view
            stats; only the named ones while `names_hidden` is present
            (`total_recipients` stays the whole audience, as
            GET /broadcasts/{id}/acknowledgements' meta does).
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          description: When the CALLING user acknowledged (null if they haven't; present
            when require_acknowledgment).
        requested_action:
          allOf:
          - "$ref": "#/components/schemas/CommsRequestedAction"
          nullable: true
          description: |
            The one thing this broadcast asks THIS reader to do, as the web
            broadcast page draws it. `null` unless it was sent to the caller
            with one. Returned by GET /broadcasts/{id} only.
    BroadcastAttachment:
      type: object
      required:
      - id
      - filename
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
          nullable: true
        byte_size:
          type: integer
        url:
          type: string
          description: ActiveStorage blob URL (attachment disposition).
    BroadcastComment:
      type: object
      required:
      - id
      - body
      - created_at
      properties:
        id:
          type: integer
        parent_id:
          type: integer
          nullable: true
          description: The top-level comment this reply answers; null on a top-level
            comment.
        body:
          type: string
          description: |
            The comment's words. Each @mention is the platform token
            `@[Name](mention:<user id>)`, as in post comments — draw it as the
            person's name.
        mentioned_user_ids:
          type: array
          items:
            type: integer
          description: The people this comment mentions (each may open the broadcast).
        created_at:
          type: string
          format: date-time
        edited_at:
          type: string
          format: date-time
          nullable: true
          description: When the words were last edited after posting; null when never.
        held_for_review:
          type: boolean
          description: |
            True while the comment waits for a moderator. Only its author is
            sent such a comment: show it as waiting, with no reply or reaction.
        author:
          "$ref": "#/components/schemas/BroadcastUser"
        replies:
          type: array
          description: |
            On the list reads (GET /broadcasts/{id}/comments and
            `recent_comments`) only: this top-level comment's visible replies,
            oldest first, each in this same shape without `replies`.
          items:
            type: object
        attachments:
          type: array
          description: This comment's file attachments (DriveItem media).
          items:
            "$ref": "#/components/schemas/BroadcastAttachment"
        reader_translation:
          type: object
          nullable: true
          description: |
            The comment in the caller's own language, as the broadcast page
            shows it. `null` when it reads as written — the caller's own
            comment, one already in their language, or a company that does not
            translate for readers — and on the create / update responses.
            `body` above is always the original, for "Show original".
          properties:
            language:
              type: string
              description: The caller's language code.
            state:
              type: string
              enum:
              - translated
              - pending
              - awaiting_review
              - failed
              description: |
                `pending` means the translation is queued — the next read of
                the thread carries it.
            body:
              type: string
              nullable: true
              description: The translated words (mention tokens kept as in `body`);
                present only when `state` is `translated`.
            label:
              type: object
              nullable: true
              description: The facts for the "Translated by AI · Show original" line;
                present only when `state` is `translated`.
              properties:
                status:
                  type: string
                checked_by:
                  type: string
                  nullable: true
                source_language:
                  type: string
        can_edit:
          type: boolean
          description: Whether the CALLING user can edit this comment — a company
            admin or a Broadcast / Communications app admin, or its author for 15
            minutes after posting while Communications allows editing posts and comments.
            The comment must be on a published broadcast that takes comments.
        can_delete:
          type: boolean
          description: |
            Whether the CALLING user can delete this comment. Mirrors the web
            comment "delete" visibility — true for the comment's author, a
            company admin, or a Broadcast / Communications app admin, and only
            when the token holds `destructive:broadcasts`.
    BroadcastCommentWriteResult:
      type: object
      description: Response for comment create / update.
      required:
      - success
      - comment
      properties:
        success:
          type: boolean
        held:
          type: boolean
          description: True when content moderation held the comment for review (hidden
            until approved).
        message:
          type: string
        comment:
          "$ref": "#/components/schemas/BroadcastComment"
    BroadcastInput:
      type: object
      description: |
        Writable broadcast attributes. On create (Send Now) supply at least one
        recipient target: `audience_id`, `extra_user_ids`, `audience_criteria`,
        or the top-level `notification_recipient_group_ids`.

        Referenced by `PATCH /broadcasts/{id}` only — the POST body inlines its
        own copy (with `description` required). Nothing is required here because
        PATCH is a PARTIAL edit: send just the attributes you are changing.
        Marking `title` and `description` required stopped a generated client
        from expressing the edit this endpoint is built for (toggling
        `publish_to_signage`, flipping `allow_comments`) without resending the
        whole body.

        `extra_user_ids` is accepted on CREATE only. Recipients and the
        `channels` mix are fixed at create time; sending either on a PATCH
        changes nothing and comes back named in `warnings`.
      properties:
        title:
          type: string
        description:
          type: string
        is_critical:
          type: boolean
        require_acknowledgment:
          type: boolean
        allow_comments:
          type: boolean
        allow_reactions:
          type: boolean
        audience_id:
          type: integer
          nullable: true
          description: A saved CommsHub audience to send to.
        extra_user_ids:
          type: array
          description: Specific user ids to send to (in addition to any groups/audience).
          items:
            type: integer
        audience_criteria:
          type: array
          description: |
            Attribute-based audience filters, each a typed hash — e.g.
            `{ "type": "role", "roles": ["member"] }`,
            `{ "type": "job_title", "titles": ["Area Manager"] }`,
            `{ "type": "department", "ids": [1,2] }`,
            `{ "type": "location", "ids": [3] }`.
          items:
            type: object
            additionalProperties: true
        publish_to_signage:
          type: boolean
          description: |
            Mark this broadcast for the break-room screen (Digital Signage)
            rotation — see the POST /broadcasts request body for what the
            marking does and does not promise.

            PATCH semantics: OMIT the key to leave the current selection alone;
            send `false` to take the broadcast back off the screens. The marker
            is removed rather than stored as `false`.
        signage_location_ids:
          type: array
          description: |
            Narrow `publish_to_signage` to specific sites. Empty means every
            screen. Ids outside the caller's business are dropped, and the key
            is ignored unless `publish_to_signage` is on.
          items:
            type: integer
    BroadcastListMeta:
      type: object
      description: |
        Pagination + segment counts for the list endpoint (parity with
        GET /inspections meta). All segment_counts are NOT-READ counts over the
        received inbox, independent of the active `filter`:
          * all         — received & not read by the caller
          * critical    — received & marked critical & not read
          * acknowledge — received & acknowledgment-required & not read
        (`all` is itself the unread total, so there is no separate `unread` key.)
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      - segment_counts
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        segment_counts:
          type: object
          required:
          - all
          - critical
          - acknowledge
          properties:
            all:
              type: integer
            critical:
              type: integer
            acknowledge:
              type: integer
    BulkOperationRefusal:
      type: object
      required:
      - error
      properties:
        error:
          type: string
    BulkOperationItems:
      type: array
      minItems: 1
      description: The records to write. A create item carries the record's attributes;
        an update item its `id` and the attributes to change; a delete item its `id`.
        A shift item takes `location_id`, `scheduling_group_id`, `title`, `start_time`,
        `end_time`, `required_staff_count`, `shift_type`, `notes` and `assignments`
        (each `user_id` and `status`); an assignment item `shift_id`, `user_id`, `status`,
        `assignment_type` and, on update, `check_in`, `check_out` and `hours_worked`.
      items:
        type: object
        additionalProperties: true
    BulkOperationOptions:
      type: object
      properties:
        validation_mode:
          type: string
          enum:
          - strict
          - lenient
          default: strict
          description: "`strict` stops the operation at the first bad item; `lenient`
            records it and continues."
        conflict_resolution:
          type: string
          enum:
          - skip
          - overwrite
          - fail
          default: skip
          description: What a create does when the record already exists. `overwrite`
            can make the request a final act — see the scopes above.
    BulkOperationTypedRequest:
      type: object
      required:
      - items
      properties:
        operation_type:
          type: string
          enum:
          - create
          - update
          - delete
          default: create
        items:
          "$ref": "#/components/schemas/BulkOperationItems"
        options:
          "$ref": "#/components/schemas/BulkOperationOptions"
    BulkOperationSummary:
      type: object
      properties:
        id:
          type: integer
        business_id:
          type: integer
        user_id:
          type: integer
          nullable: true
        job_id:
          type: string
          format: uuid
        data_type:
          type: string
        operation_type:
          type: string
        status:
          type: string
          enum:
          - pending
          - processing
          - completed
          - failed
          - cancelled
        total_items:
          type: integer
        processed_items:
          type: integer
        successful_items:
          type: integer
        failed_items:
          type: integer
        error_messages: {}
        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
        updated_at:
          type: string
          format: date-time
    ChatThreadFollow:
      type: object
      properties:
        root_id:
          type: integer
        following:
          type: boolean
    ChatMessageReminder:
      type: object
      properties:
        id:
          type: integer
        remind_at:
          type: string
          format: date-time
          description: When the reminder is due.
        state:
          type: string
          enum:
          - active
          - snoozed
        room_id:
          type: integer
        message_id:
          type: integer
        available:
          type: boolean
          description: False when the caller can no longer open the message — no notice
            will go out
          and `message` is null.:
        message:
          nullable: true
          allOf:
          - "$ref": "#/components/schemas/ChatMessage"
    ChatMessageReminderSet:
      type: object
      properties:
        reminder:
          "$ref": "#/components/schemas/ChatMessageReminder"
        moved:
          type: boolean
          description: True when the caller's open reminder on this message was moved
            to the new time instead of a second one being set (the answer is then
            200).
        message:
          type: string
          description: The sentence to show, in the caller's language and time zone
            — the web's own — e.g. "I'll remind you in 20 minutes." or "I'll remind
            you today at 8:29 AM."; when `moved`, it starts by saying the earlier
            reminder was moved.
        confirmation:
          type: string
          description: The same sentence as `message`
          under the name this answer first used.:
    ChatAskAiIdentity:
      type: object
      description: The Ask AI assistant's admin-customizable identity. Shared shape
        for the boot config's ask_ai_account / ask_ai_chat and for GET /chat/ask_ai/identity.
        Blank description / history_tip mean "no custom copy" — the client keeps its
        own default subtitle and tip.
      properties:
        id:
          type: integer
          nullable: true
          description: Assistant principal's user id; null until provisioned.
        name:
          type: string
        avatar_url:
          type: string
          nullable: true
          description: Custom uploaded icon only; null ⇒ use the client's own glyph.
        avatar_updated_at:
          type: integer
          nullable: true
          description: Epoch seconds — cache-busting stamp.
        description:
          type: string
          nullable: true
        greeting:
          type: string
          nullable: true
          description: The assistant's opening line (Ask AI › Guidelines › Assistant);
            null ⇒ the client's own welcome.
        history_tip:
          type: string
          nullable: true
        history_search:
          type: boolean
          description: ask_ai_account only — retrieval-grounded answers are live for
            this tenant.
    ChatMemberImportPerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        detail:
          type: string
          description: What tells namesakes apart (job title, location…) — only what
            the caller's directory shows; never an email the directory hides.
    ChatMemberImport:
      type: object
      properties:
        rows:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
                description: The person's place in the list.
              status:
                type: string
                enum:
                - ready
                - pick
                - not_found
                - unavailable
              given:
                type: object
                description: What the list said about them (their own words; the email
                  is the list's, not the directory's).
                properties:
                  name:
                    type: string
                  email:
                    type: string
                  employee_id:
                    type: string
                  job_title:
                    type: string
                  department:
                    type: string
                  location:
                    type: string
                  list:
                    type: string
                    description: Which of several lists in the content they are on
                      (see `lists`).
              match:
                type: string
                nullable: true
                enum:
                - email
                - employee_id
                - name
                - first_name
                - name_from_email
                - close
                - conversation
                -
                description: 'How they were found — `conversation`: copied from the
                  room `from_room_id` named. A first_name, name_from_email or close
                  match is never ready — a pick, or not_found when too many share
                  the first name.'
              person:
                "$ref": "#/components/schemas/ChatMemberImportPerson"
              reason_code:
                type: string
                description: unavailable rows (and unavailable candidates) — you /
                  already_member (only where the caller may see the member list) /
                  listed_twice (a candidate already on the list) / deactivated / chat_not_open
                  / information_barrier / barrier_room / barrier_list
              reason:
                type: string
                description: The sentence to show beside the row
                in the caller's language.:
              candidates:
                type: array
                description: pick rows (and an unavailable row whose candidates all
                  are) — choose one; best first (those the list's own title, department
                  or location point at come first, `hinted`).
                items:
                  allOf:
                  - "$ref": "#/components/schemas/ChatMemberImportPerson"
                  - type: object
                    properties:
                      available:
                        type: boolean
                      reason_code:
                        type: string
                        nullable: true
                      reason:
                        type: string
                        nullable: true
                      hinted:
                        type: boolean
        counts:
          type: object
          properties:
            ready:
              type: integer
            pick:
              type: integer
            not_found:
              type: integer
            unavailable:
              type: integer
            duplicates:
              type: integer
              description: People listed twice (shown once).
            found:
              type: integer
              description: People the content held.
            read:
              type: integer
              description: People read (at most `max_people`).
        truncated:
          type: boolean
          description: The content held more than `max_people` people; the first ones
            were read.
        max_people:
          type: integer
        email_shown:
          type: boolean
          description: Whether the caller's directory shows email — when false, emails
            in the list are not used to find people.
        sends:
          type: object
          nullable: true
          description: What confirming will SEND, so the review is an honest last
            look before anyone is told (null when it sends nothing). Built from this
            door's own rules, never typed beside them.
          properties:
            sentence:
              type: string
              description: The sentence to show
              in the caller's language.:
            now:
              type: integer
              description: How many of the ready people are told straight away.
            later:
              type: integer
              description: How many are told later
              once something else happens first.:
        lists:
          type: array
          description: When the content holds several lists (teams, groups), each
            one's name and how many people are on it — let the person choose.
          items:
            type: object
            properties:
              name:
                type: string
              count:
                type: integer
        list:
          type: object
          description: What the list says about itself.
          properties:
            name:
              type: string
              description: Its name as written.
            channel_name:
              type: string
              description: That name as a channel name (lowercase words joined by
                dashes).
            channel_name_error:
              type: string
              description: Set when no channel can take that name (one already has
                it).
            description:
              type: string
              description: Its topic or purpose — a channel's topic.
            visibility:
              type: string
              enum:
              - public
              - private
            source:
              type: string
              description: What the content is
              in a few words.:
            copied_from:
              type: string
              description: The group or channel the people were copied from (`from_room_id`),
                as its members see it named ("#support"). Set only for a copy.
        settings:
          type: object
          nullable: true
          description: The list's settings mapped onto Chat's (null for an existing
            conversation).
          properties:
            values:
              "$ref": "#/components/schemas/ChatRoomPermissionCodes"
            carried:
              type: array
              items:
                type: object
                properties:
                  key:
                    type: string
                  code:
                    type: string
                  label:
                    type: string
                    description: The list's own question.
                  value:
                    type: string
                    description: The list's own answer.
                  setting:
                    type: string
                    description: Chat's setting, in the caller's language.
                  choice:
                    type: string
                    description: Chat's choice, in the caller's language.
            not_carried:
              type: array
              items:
                type: object
                properties:
                  label:
                    type: string
                  value:
                    type: string
                  reason:
                    type: string
        photo:
          type: object
          nullable: true
          description: The list's own photo, fetched now (null when it has none, or
            for an existing conversation).
          properties:
            status:
              type: string
              enum:
              - ready
              - failed
            signed_id:
              type: string
              description: ready — pass as `photo_signed_id` when creating (kept for
                a day
              only for the caller).:
            url:
              type: string
              description: ready — a preview link (an hour).
            host:
              type: string
              description: Where the link pointed.
            looks_default:
              type: boolean
              description: The source app's own default picture, by the look of the
                link — offer it unticked.
            reason:
              type: string
              description: failed — why it couldn't be used.
        conversation:
          type: object
          properties:
            for:
              type: string
              enum:
              - new
              - room
            group_member_limit:
              type: integer
              description: A group holds this many people
              the caller included; past it a new conversation must be a private channel.:
            can_create_channels:
              type: boolean
            direct_messages_enabled:
              type: boolean
              description: One person makes a direct message — false when the tenant
                switched them off (pick a second person for a group).
            room_id:
              type: integer
            room_type:
              type: string
              enum:
              - group
              - channel
            member_count:
              type: integer
            over_limit_if_all_added:
              type: boolean
              description: Adding every ready person would take this group past the
                limit — it must become a private channel first (POST /chat/rooms/{id}/convert_to_channel).
    ChatChannelAvatarColor:
      type: string
      enum:
      - red
      - orange
      - amber
      - green
      - teal
      - cyan
      - blue
      - indigo
      - purple
      - pink
      example: teal
    ChatChannelAvatar:
      type: object
      properties:
        emoji:
          type: string
          nullable: true
          description: One emoji; null draws the colour alone.
          example: "\U0001F4E3"
        color:
          "$ref": "#/components/schemas/ChatChannelAvatarColor"
        source:
          type: string
          enum:
          - auto
          - chosen
          description: "`auto` — picked from the name (a rename picks again); `chosen`
            — someone picked it, and it stays."
    ChatChannelAvatarChoice:
      type: object
      required:
      - color
      description: Channel only. One of the palette colours and at most one emoji;
        anything else is 422 `invalid_avatar` and nothing is written.
      properties:
        emoji:
          type: string
          nullable: true
          description: A single emoji; blank for the colour alone.
        color:
          "$ref": "#/components/schemas/ChatChannelAvatarColor"
    ChatMessageEscalation:
      type: object
      properties:
        state:
          type: string
          enum:
          - pending
          - notified
          - everyone_confirmed
          - not_delivered
          - barred
          - expired
          description: "`pending` until the wait is up; then `notified` (the backup
            was told), `everyone_confirmed` (nobody was left to name), `not_delivered`
            (the backup left, or has these notices off — the sender was sent the list),
            or `barred` (an information barrier now stands between the sender and
            the backup, so the backup was not told — the sender was sent the list).
            `expired` — still pending more than 14 days after it was sent (the sweep
            never reached it: the worker was down, Chat was switched off): nobody
            will be told any more, so there is nothing to cancel (`can_cancel` false;
            DELETE …/escalation still takes the backup off)."
        backup:
          nullable: true
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
        escalate_at:
          type: string
          format: date-time
          nullable: true
          description: When the backup is told if someone has still not confirmed.
        escalated_at:
          type: string
          format: date-time
          nullable: true
        pending_count:
          type: integer
          nullable: true
          description: How many had not confirmed when it was decided.
        wait_label:
          type: string
          nullable: true
          example: 2 hours
        can_cancel:
          type: boolean
    ChatNotificationMirror:
      type: object
      properties:
        enabled:
          type: boolean
          description: False while `retired`, whatever was chosen.
        retired:
          type: boolean
          description: 'True while the company''s "One interruption per item" is on:
            nothing is copied (Needs you carries what needs the person), and PUT answers
            409 `retired`.'
        retired_reason:
          type: string
          nullable: true
          description: The sentence to show while `retired`.
        categories:
          type: array
          items:
            type: string
          description: The kinds chosen.
        available:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              label:
                type: string
              description:
                type: string
                nullable: true
              selected:
                type: boolean
    ChatMessageCard:
      type: object
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - approval
          - acknowledge
          - sign
          - link
          - notice
          - open_shift
          - app_event
          description: "`approval` — an approvals-registry row; `acknowledge` / `sign`
            — an Inbox ask's one button; `link` — an ask its page finishes; `notice`
            — a notice to see; `open_shift` / `app_event` — an app's update in a channel
            (no recipient)."
        source_key:
          type: string
          description: An approval's queue (leave_requests, timesheets, shift_claims,
            …); an ask card's source type (Broadcast, Task, …).
        record_id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - done
          - gone
          description: '`done` — an ask card its person answered, here or anywhere
            ("Acknowledged by you", "Signed by you"). `gone` — decided elsewhere,
            or no longer waiting on the caller. What happened is in `outcome` / `decided_via`
            / `gone_reason`.'
        decided_by:
          nullable: true
          description: Who decided it — from this card (approved / rejected), or elsewhere
            (`gone` with an `outcome`). Name them "you" when `id` is the caller.
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
        decided_at:
          type: string
          format: date-time
          nullable: true
        outcome:
          type: string
          nullable: true
          enum:
          - approved
          - rejected
          -
          description: On a `gone` card, the decision made elsewhere through the approvals
            registry (the Inbox, the approvals page, an emailed link, Teams, Slack,
            Ask AI, another person's card) — "Approved by you, from your Inbox". Null
            when it went another way (withdrawn, moved on, decided in the app's own
            page) or before 2026-10-18.
        decided_via:
          type: string
          nullable: true
          enum:
          - inbox
          - approvals_page
          - home
          - phone
          - email
          - teams
          - slack
          - ask_ai
          - chat
          -
          description: Where that decision was made, when the door said so; null when
            not known (say who and what only).
        gone_reason:
          type: string
          nullable: true
          description: On a `gone` card with no `outcome`, the sentence the app closed
            its Inbox ask with ("Marcus withdrew this request."); null when it gave
            none — say "Already handled — this is no longer waiting on you."
        can_approve:
          type: boolean
        can_reject:
          type: boolean
          description: False when the queue asks a reason for a decline
          or asks before one (a leave request) — see `decline_needs_reason`.:
        decline_needs_reason:
          type: boolean
          description: Open `row.deep_link` to decline it with a reason.
        can_act:
          type: boolean
          description: 'A card the caller can answer here: draw `row.act_label` as
            its button (verdict: the kind; `seen` for a notice, `claim` for an open
            shift).'
        pending_approval:
          type: boolean
          description: An open-shift card claimed here whose claim waits for a manager
            ("Claimed by Dana · waiting for approval").
        act_needs_app:
          type: boolean
          description: 'An ask card whose button asks for statements first: open `row.deep_link`
            ("Open to finish") instead.'
        row:
          type: object
          description: The approvals registry's row as drawn for the caller. Details,
            amount and urgency only while pending.
          properties:
            kind_label:
              type: string
            subject:
              type: string
            subject_secondary:
              type: string
              nullable: true
            summary:
              type: string
              nullable: true
            details:
              type: array
              items:
                type: object
                required:
                - key
                properties:
                  key:
                    type: string
                    description: The line's stable snake_case name — find a line by
                      it, never by `label`, which is reworded and translated (`missing_day_requests`
                      on a timesheet with missing-day requests waiting; `cannot_approve`
                      / `cannot_approve_yet` on a shift claim Approve refuses; `balance`,
                      `shift_conflict`, `coverage_over_limit` / `coverage_warning`
                      on a leave request). A line its source names no key for is keyed
                      by its English label (`due`, `type`), and a line with no label
                      is `note`. Never localized. The Home Pending Approvals card's
                      `decidables[].details` (GET /api/v1/home) carry the same lines.
                  count:
                    type: integer
                    nullable: true
                    description: 'What the line counts, where it counts something
                      (`missing_day_requests`: the missing-day requests still waiting).'
                  label:
                    type: string
                    nullable: true
                  value:
                    type: string
                    nullable: true
                  from:
                    type: string
                    nullable: true
                    description: A change's old value (with `to`).
                  to:
                    type: string
                    nullable: true
            amount:
              type: string
              nullable: true
            urgency_label:
              type: string
              nullable: true
            deep_link:
              type: string
              nullable: true
              description: The item's page on the web (a path).
            deep_link_label:
              type: string
            approve_label:
              type: string
            reject_label:
              type: string
            act_label:
              type: string
              nullable: true
              description: An ask card's button ("Acknowledge", "Sign"); null on an
                approval.
            blocked_reason:
              type: string
              nullable: true
            image_url:
              type: string
              nullable: true
              description: A channel card's picture — an award's card picture (1200×630)
                — at an absolute address on the company's own host. It stops answering
                when the moment is no longer shared; draw the card without it then.
            image_alt:
              type: string
              nullable: true
              description: The picture's text alternative.
            people:
              type: array
              nullable: true
              description: 'The day''s Celebrations card: the first people celebrated,
                each with their card picture (`image_url`, as above) when there is
                one.'
              items:
                type: object
                properties:
                  name:
                    type: string
                  line:
                    type: string
                    nullable: true
                    description: 'What they are celebrated for ("5 Year Work Anniversary",
                      "Certified: Food Safety").'
                  image_url:
                    type: string
                    nullable: true
            more:
              type: string
              nullable: true
              description: 'The Celebrations card''s line naming the rest ("And 3
                more: …").'
            more_copy:
              type: object
              nullable: true
              additionalProperties: true
              description: The same line as a sentence a client can say in its reader's
                language (a Translation::Sentence hash); a client that does not translate
                shows `more`.
    ChatRoomAppEvents:
      type: object
      properties:
        room_id:
          type: integer
        events:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - open_shifts
                - coverage_gaps
                - team_recognition
                - celebrations
              label:
                type: string
              description:
                type: string
                nullable: true
                description: The line under the name, where it has one (Celebrations).
              true:
                type: boolean
        can_change:
          type: boolean
    ChatIncomingWebhook:
      type: object
      properties:
        id:
          type: integer
        room_id:
          type: integer
        name:
          type: string
        url_hint:
          type: string
          nullable: true
          description: The URL's last four characters ("…a1b2").
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        created_by_active:
          type: boolean
          nullable: true
          description: false once the person who added it is no longer a member of
            the company; null when nobody is recorded.
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: When it last posted.
        last_refused_at:
          type: string
          format: date-time
          nullable: true
          description: When a post to it was last refused (at most one write a minute).
        last_refusal:
          type: string
          nullable: true
          enum:
          - rate_limited
          - invalid_payload
          - no_text
          - subscription_not_confirmed
          - invalid_sns_signature
          - payload_too_large
          description: Why it was refused.
        last_refusal_message:
          type: string
          nullable: true
          description: 'The refusal as one sentence, while it is newer than the last
            post ("Last refused 5 minutes ago: …").'
        revoked_at:
          type: string
          format: date-time
          nullable: true
    ChatOnShiftPerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        avatar_url:
          type: string
          nullable: true
        avatar_updated_at:
          type: integer
          nullable: true
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        role:
          type: string
          nullable: true
          example: Shift Lead
          description: 'Their job function — the role a shift is staffed by — named
            for people (a stored key `crew_lead` reads "Crew Lead"; typed text is
            kept), as `GET /chat/on_shift/options` `role_options` labels it. A label
            to show: `?role=` takes the stored value (`role_options[].value`, `roles`).
            Null when they have none. A shift has no single role of its own.'
        shift_starts_at:
          type: string
          format: date-time
          nullable: true
          description: 'The start of their CURRENT shift: clocked in, the shift they
            clocked in to; else, of the shifts they are on now, the one most recently
            begun (on `GET /chat/on_shift`, the one at the asked location or a site
            beneath it). Show it on the shift''s clock (`shift_time_zone`), as the
            Shifts app does, with the zone''s short label when that is not the viewer''s
            ("8:00 AM – 4:00 PM PDT"). Null — with `shift_ends_at` and `shift_time_zone`
            — when the caller may not see this person''s shift: they see their own,
            the people they manage and the people at their own locations; company
            admins and Shifts app admins see everyone''s. Show role and location only
            then.'
        shift_ends_at:
          type: string
          format: date-time
          nullable: true
          description: That shift's end — the next morning for an overnight shift.
            Null for a walk-in shift, which has no scheduled end (show "since <start>"),
            and when the caller may not see this person's shift (see `shift_starts_at`).
        shift_time_zone:
          type: string
          nullable: true
          example: America/Los_Angeles
          description: 'The IANA zone the shift''s times are read in — the zone the
            Shifts app shows the shift in: its site''s own zone, else the company''s.
            Present whenever `shift_starts_at` is; null together with it (the caller
            may not see this person''s shift).'
    ChatLegalHold:
      type: object
      properties:
        id:
          type: integer
        reason:
          type: string
        placed_at:
          type: string
          format: date-time
        placed_by:
          type: string
          description: Who placed it ("System" for a hold an app placed).
        lifted_at:
          type: string
          format: date-time
          nullable: true
        lifted_by:
          type: string
          nullable: true
        lift_reason:
          type: string
          nullable: true
    ClassifiedListing:
      type: object
      description: A listing as Browse, My Favorites and My Listings list it.
      properties:
        id:
          type: integer
        title:
          type: string
        listing_type:
          type: string
          enum:
          - for_sale
          - free_giveaway
          - wanted_request
          - services_offered
        listing_type_display:
          type: string
          example: For Sale
        price:
          type: number
          nullable: true
          description: Null on a wanted request, which shows no price.
        price_display:
          type: string
          nullable: true
          example: "$250.00"
        status:
          type: string
          enum:
          - active
          - pending_approval
          - fulfilled
          - expired
          - deleted
          description: An active listing past its expiry date reads `expired`.
        category:
          type: string
          nullable: true
          description: The category's name.
        category_id:
          type: integer
          nullable: true
        location:
          type: string
          nullable: true
        location_id:
          type: integer
          nullable: true
        poster_name:
          type: string
        has_photos:
          type: boolean
        views_count:
          type: integer
          nullable: true
          description: Null while the app's view counter is off.
        favorites_count:
          type: integer
          nullable: true
          description: Null while the app's Favorites setting is off.
        created_at:
          type: string
          format: date-time
    ClassifiedMyListing:
      description: One of the caller's own listings (GET /classified_listings/my_listings).
      allOf:
      - "$ref": "#/components/schemas/ClassifiedListing"
      - type: object
        properties:
          status_text:
            type: string
            description: The state in words; a `deleted` listing reads Deleted, Removed
              by moderator or Rejected.
          removed_by_moderator:
            type: boolean
          rejection_reason:
            type: string
            nullable: true
            description: The moderator's reason, on a rejected listing.
          repost_path:
            type: string
            nullable: true
            description: The page that posts a fixed copy of a rejected listing, until
              one is posted.
    ClassifiedListingDetail:
      description: One listing in full (GET, POST and PATCH answer this).
      allOf:
      - "$ref": "#/components/schemas/ClassifiedListing"
      - type: object
        properties:
          description:
            type: string
          currency:
            type: string
            example: USD
          location_detail:
            type: string
            nullable: true
          contact_method:
            type: string
            enum:
            - internal_chat
            - email
            - phone
            - text
          contact_method_display:
            type: string
          contact_email:
            type: string
            description: Present only when `contact_method` is `email`.
          contact_phone:
            type: string
            description: Present only when `contact_method` is `phone` or `text`.
          tags:
            type: array
            items:
              type: string
          photo_count:
            type: integer
          video_count:
            type: integer
          expires_at:
            type: string
            format: date-time
            nullable: true
          days_until_expiry:
            type: integer
          favorited_by_me:
            type: boolean
          can_edit:
            type: boolean
            description: Whether PATCH /classified_listings/{id} would take this caller's
              edit.
          can_delete:
            type: boolean
            description: Whether DELETE /classified_listings/{id} would take this
              caller's removal.
          poster_department:
            type: string
            nullable: true
            description: Present only when the app shows the poster's department.
    ClassifiedListingInput:
      type: object
      description: A listing's fields. On POST, `title`, `description` and `listing_type`
        are needed, and the app's settings may require a category, a location or a
        contact detail too; on PATCH send only what changes.
      properties:
        title:
          type: string
        description:
          type: string
        listing_type:
          type: string
          enum:
          - for_sale
          - free_giveaway
          - wanted_request
          - services_offered
        classified_category_id:
          type: integer
          description: An id from GET /classified_listings/categories.
        price:
          type: number
          description: Ignored for a free giveaway or wanted request.
        currency:
          type: string
          example: USD
        location:
          type: string
          description: A pickup or meeting place in words.
        location_id:
          type: integer
          description: One of the organization's locations.
        contact_method:
          type: string
          enum:
          - internal_chat
          - email
          - phone
          - text
          description: How people reach the seller. `phone` and `text` need `contact_phone`;
            `email` publishes `contact_email`, else the seller's own address.
        contact_email:
          type: string
        contact_phone:
          type: string
        tags:
          type: string
          description: Comma-separated, as one string — a list is refused with 422.
        photos:
          type: array
          description: Image files, in a multipart/form-data request only.
          items:
            type: string
            format: binary
    ClassifiedCategory:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        icon:
          type: string
          description: A Font Awesome class.
        color:
          type: string
          example: "#007bff"
        active_listings_count:
          type: integer
          description: How many listings Browse shows in the category.
        postable:
          type: boolean
          description: False when the app's prohibited categories setting refuses
            new listings in it.
    ClassifiedPageMeta:
      type: object
      properties:
        total:
          type: integer
        page:
          type: integer
        per_page:
          type: integer
        total_pages:
          type: integer
    ClassifiedErrors:
      type: object
      required:
      - errors
      properties:
        errors:
          type: array
          items:
            type: string
    ClassifiedsRefusal:
      type: object
      required:
      - error
      properties:
        error:
          type: string
          example: You cannot delete this listing.
    CommsHubPagination:
      type: object
      required:
      - page
      - per_page
      - total_count
      - total_pages
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total_count:
          type: integer
          example: 3
        total_pages:
          type: integer
          example: 1
    CommsHubPublication:
      type: object
      required:
      - id
      - name
      - slug
      - description
      - status
      - cadence_cron
      - audience_id
      - email_channel_enabled
      - inapp_channel_enabled
      - inapp_requires_acknowledgement
      - public_archive_enabled
      - issues_count_cache
      properties:
        id:
          type: integer
        name:
          type: string
          example: The Weekly Mango
        slug:
          type: string
          example: the-weekly-mango
        description:
          type: string
          nullable: true
        status:
          type: string
          enum:
          - active
          - paused
          - archived
        cadence_cron:
          type: string
          nullable: true
          description: The send cadence as a cron expression; null when the publication
            has no fixed cadence.
          example: 0 9 * * 1
        audience_id:
          type: integer
          nullable: true
          description: The publication's default audience (`/comms_hub/audiences/{id}`).
        email_channel_enabled:
          type: boolean
        inapp_channel_enabled:
          type: boolean
        inapp_requires_acknowledgement:
          type: boolean
          description: Whether the in-app Must-Read copy asks readers to acknowledge
            it (and so sends reminders and manager escalation).
        public_archive_enabled:
          type: boolean
        issues_count_cache:
          type: integer
          description: Cached number of issues.
    CommsHubIssue:
      type: object
      required:
      - id
      - title
      - slug
      - preview_text
      - status
      - sent_at
      - corrected_at
      - scheduled_at
      - audience_id
      - custom_audience
      - topics
      - blocks_count
      - ai_assisted
      - audience_deleted
      properties:
        id:
          type: integer
        title:
          type: string
          description: The subject line — translated when `language` is not `en`.
        slug:
          type: string
          nullable: true
        preview_text:
          type: string
          nullable: true
          description: The inbox preview line — translated when `language` is not
            `en`.
        status:
          type: string
          enum:
          - draft
          - in_review
          - scheduled
          - sent
          - archived
          - rejected
        sent_at:
          type: string
          format: date-time
          nullable: true
        corrected_at:
          type: string
          format: date-time
          nullable: true
          description: When the sent issue's online version (its page, in-app post
            and public archive) was last corrected; the web marks it "Updated" with
            this date. Emails already delivered keep what was sent. Null when never
            corrected.
        scheduled_at:
          type: string
          format: date-time
          nullable: true
        audience_id:
          type: integer
          nullable: true
          description: The issue's own saved audience; null when it uses the publication's,
            when `custom_audience` is true, or when the audience was deleted after
            the issue went out (`audience_deleted`).
        audience_deleted:
          type: boolean
          description: True when the issue was sent to a saved audience that has since
            been deleted.
        custom_audience:
          type: boolean
          description: True when the issue goes to a selection made in Review & send
            — several saved audiences, groups or people — rather than one saved audience
            or the publication's.
        topics:
          type: array
          description: The issue's Topics (normalized topic names) — the chips shown
            under it, carried onto its in-app post.
          items:
            type: string
          example:
          - company-update
          - internal-comms
        blocks_count:
          type: integer
        ai_assisted:
          type: boolean
          description: True when the issue was written with AI help AND the caller
            may be told so — the company shows that mark to readers, or the caller
            is the issue's author, the publication's owner or a Communications app
            admin. Never the raw stored flag.
        language:
          type: string
          description: Present only for a sent issue. The language `title`, `preview_text`
            and `blocks` are in (`en` = the original).
          example: es
        available_languages:
          type: array
          description: 'Present only for a sent issue. `en` plus every language it
            can be read in: on the list, the versions frozen for the send; for one
            issue, also every language the company offers its readers, which AI translates
            on request (`reviewed_languages` says which a person checked).'
          items:
            type: string
          example:
          - en
          - es
        reviewed_languages:
          type: array
          items:
            type: string
          description: 'Present beside `available_languages`: the languages a person
            checked — the version frozen for the send, or a reader edition a person
            approved for the issue as it reads now. Every other available language
            is translated by AI on request; mark the two apart as the web picker does
            ("Spanish · reviewed").'
          example:
          - es
        source_fingerprint:
          type: string
          nullable: true
          description: Present only for a sent issue. Fingerprint of the delivered
            content, stable across languages.
    CommsHubIssueDetail:
      allOf:
      - "$ref": "#/components/schemas/CommsHubIssue"
      - type: object
        required:
        - blocks
        - campaign
        - requested_action
        properties:
          requested_action:
            allOf:
            - "$ref": "#/components/schemas/CommsRequestedAction"
            nullable: true
            description: The one thing this issue asks THIS reader to do, as the web
              issue page draws it. `null` unless it was sent to the caller with one.
          translation:
            type: string
            enum:
            - original
            - reviewed
            - ai
            - pending
            - unavailable
            description: Present only for a sent issue that carries its delivered
              document. `reviewed` — a person approved this language; `ai` — translated
              by AI, not yet checked (say so to the reader); `pending` — being translated,
              the original is returned meanwhile (ask again in a minute); `unavailable`
              — not offered in that language, the original is returned.
          checked_before_correction:
            type: boolean
            description: Present only beside `translation`. True when the language
              version returned was checked by a person before the issue's latest correction
              (`corrected_at`), so it keeps the words as they were then — say so and
              offer the original (`lang=en`), as the web reader page does. False for
              the original, an AI translation, or a version checked after the correction.
          campaign:
            allOf:
            - "$ref": "#/components/schemas/CommsHubCampaign"
            nullable: true
            description: The campaign the issue is in — for a caller who manages the
              issue or may change that campaign (otherwise null, as for an issue in
              no campaign); `can_change` is false for a token without `write:broadcasts`.
          blocks:
            type: array
            items:
              "$ref": "#/components/schemas/CommsHubIssueBlock"
    CommsHubCampaign:
      type: object
      description: A Communications campaign as a composer draws it (the Campaign
        field).
      required:
      - id
      - name
      - status
      - kickoff_at
      - ended_at
      - open
      - can_change
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - canceled
          description: Follows the campaign's dates (a Draft turns Active on its kickoff
            date, an Active one Completed the day after it ends), unless a person
            set it.
        kickoff_at:
          type: string
          format: date-time
          nullable: true
        ended_at:
          type: string
          format: date-time
          nullable: true
        open:
          type: boolean
          description: Draft or Active — it can take new messages.
        can_change:
          type: boolean
          description: 'Whether the caller may take a message out of it (false for
            a token without `write:broadcasts`). False: show its name instead of a
            choice, as the web does.'
    CommsHubCampaignDetail:
      type: object
      description: One campaign as its web page shows it (GET /comms_hub/campaigns/{id}).
      required:
      - id
      - name
      - status
      - kickoff_at
      - ended_at
      - open
      - can_change
      - description
      - theme
      - owner
      - pieces_count
      - sent_count
      - follows
      - followable_triggers
      - goals
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - canceled
        kickoff_at:
          type: string
          format: date-time
          nullable: true
        ended_at:
          type: string
          format: date-time
          nullable: true
        open:
          type: boolean
          description: Draft or Active — it can take new messages and follow a trigger.
        can_change:
          type: boolean
          description: Whether the caller may change what is in it (false for a token
            without `write:broadcasts`).
        description:
          type: string
          nullable: true
        theme:
          type: string
          nullable: true
        owner:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        pieces_count:
          type: integer
          description: Pieces in the campaign, sent or not — a followed trigger counts
            once.
        sent_count:
          type: integer
          description: Pieces that went out — a followed trigger counts once, when
            it has sent any.
        follows:
          type: array
          items:
            "$ref": "#/components/schemas/CommsHubCampaignFollow"
        followable_triggers:
          type: array
          items:
            type: object
            required:
            - id
            - label
            - enabled
            properties:
              id:
                type: integer
              label:
                type: string
                description: The trigger's name on the Triggers page.
              enabled:
                type: boolean
                description: Whether the trigger is switched on (an off one sends
                  nothing new).
        goals:
          type: array
          items:
            "$ref": "#/components/schemas/CommsHubCampaignGoal"
    CommsHubCampaignFollow:
      type: object
      description: A trigger the campaign follows — ONE piece, however many triggered
        messages it sent.
      required:
      - trigger_id
      - label
      - sent_in_campaign
      - from
      - until
      - followed_at
      properties:
        trigger_id:
          type: integer
        label:
          type: string
          description: The trigger's name on the Triggers page ("Week 1 check-in").
        sent_in_campaign:
          type: integer
          description: Its triggered messages in the campaign that were sent.
        from:
          type: string
          format: date
          description: The first day its messages are taken from (the kickoff date,
            or the day the campaign was created).
        until:
          type: string
          format: date
          nullable: true
          description: The last day (the campaign's end date); null — no end date.
        followed_at:
          type: string
          format: date-time
    CommsHubCampaignFollowResult:
      type: object
      required:
      - data
      properties:
        data:
          type: object
          required:
          - follow
          - added
          - changed
          properties:
            follow:
              "$ref": "#/components/schemas/CommsHubCampaignFollow"
            added:
              type: integer
              description: Triggered messages already sent in the campaign's dates
                that joined now.
            changed:
              type: boolean
              description: False when it already followed that trigger.
    CommsHubCampaignGoal:
      type: object
      required:
      - key
      - label
      - target
      - value
      - measured
      - reached
      - note
      - summary
      - breakdown
      properties:
        key:
          type: string
          enum:
          - opens
          - clicks
          - acknowledgments
          - completions
          - completions_pct
        label:
          type: string
        target:
          type: integer
        value:
          type: integer
          nullable: true
          description: The goal's number; null when it has none (`note` says why).
        measured:
          type: boolean
        reached:
          type: boolean
        note:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
          description: How the number is put together ("Added up across pieces …").
        breakdown:
          type: array
          items:
            "$ref": "#/components/schemas/CommsHubCampaignGoalPiece"
    CommsHubCampaignGoalPiece:
      type: object
      description: 'One sent piece''s share of a goal: a message, or a followed trigger''s
        triggered messages (`trigger_id`, `sent` of them). The counted pieces'' values
        add up to the goal''s value.'
      required:
      - label
      - source_type
      - kind
      - source_id
      - trigger_id
      - sent
      - counted
      - value
      - asked
      - note
      properties:
        label:
          type: string
        source_type:
          type: string
        kind:
          type: string
          description: The kind by its name in Communications, in the caller's language
            ("Feed post", "Automated message") — words to show; switch on `source_type`.
        source_id:
          type: integer
          nullable: true
        trigger_id:
          type: integer
          nullable: true
        sent:
          type: integer
        counted:
          type: boolean
        value:
          type: integer
          nullable: true
        asked:
          type: integer
          nullable: true
          description: The people it asked to act (completion rate only).
        note:
          type: string
          description: What its value means, or why it is not counted.
    CommsHubIssueBlock:
      type: object
      required:
      - id
      - position
      - block_type
      - data
      properties:
        id:
          type: integer
        position:
          type: integer
        block_type:
          type: string
          enum:
          - text
          - image
          - image_text
          - two_columns
          - quote
          - stats
          - divider
          - hero
          - callout
          - button
          - dynamic_recognitions
          - dynamic_new_hires
          - dynamic_top_news_posts
          - dynamic_course_completions
          - dynamic_open_shifts
          - dynamic_pulse_results
          - dynamic_recent_sites
          - dynamic_recent_pages
          - dynamic_upcoming_events
          - dynamic_leader_rounds
        data:
          type: object
          additionalProperties: true
          description: |
            The block's content, keyed by its type — e.g. `text` → `{body}`,
            `image` → `{url, alt, href}`, `hero` → `{headline, subhead,
            image_url, cta_label, cta_url}`, `callout` → `{body, tone}`,
            `button` → `{label, url, tone}`. A dynamic block's resolved data
            carries `items` (rows) and, in a sent issue, `heading`,
            `description` and `metrics`.
    CommsHubAudience:
      type: object
      required:
      - id
      - name
      - summary
      - member_count_cache
      - definition_type
      properties:
        id:
          type: integer
        name:
          type: string
          example: Store managers — West
        summary:
          type: string
          nullable: true
          description: The audience's description.
        member_count_cache:
          type: integer
          nullable: true
          description: Cached member count; refreshed when the audience's rules change
            and nightly.
        definition_type:
          type: string
          nullable: true
          description: "`composition` for an audience built from composed rules; otherwise
            the `type` of its legacy rule definition; null when it has neither."
    CommsHubApprovalRequestRow:
      type: object
      description: 'An approval request as a queue row. The names are the decision
        doors'' (`approval` on `POST /approvals/{id}/approve`): `request_id` is the
        same id as `id`.'
      required:
      - id
      - request_id
      - status
      - workflow
      - requested_at
      - current_step
      - overdue
      - due_soon
      - can_act
      properties:
        id:
          type: integer
        request_id:
          type: integer
          description: The same id, under the name the decision doors use.
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - withdrawn
          - overridden
        workflow:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
        requested_by:
          "$ref": "#/components/schemas/ApprovalUser"
        requested_at:
          type: string
          format: date-time
        current_step:
          type: object
          required:
          - index
          - total
          properties:
            index:
              type: integer
              description: Zero-based index of the current step.
            total:
              type: integer
              nullable: true
            role:
              type: string
              nullable: true
              description: The role that decides this step, when the step names a
                role.
            label:
              type: string
              nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
          description: The step's deadline while the request waits; null when its
            workflow sets none, and once it is decided.
        overdue:
          type: boolean
        due_soon:
          type: boolean
          description: Inside the last quarter of the step's deadline window.
        can_act:
          type: boolean
          description: Whether the caller can decide it now — true on every queue
            row, unless the token lacks `write:broadcasts` (a read-only token decides
            nothing).
        communication:
          allOf:
          - "$ref": "#/components/schemas/CommsHubApprovalCommunicationSummary"
          nullable: true
          description: The message it is about; null when it has been deleted.
    CommsHubApprovalRequestDetail:
      type: object
      description: One approval request, as the web approval request page shows it.
      required:
      - id
      - request_id
      - status
      - workflow
      - requested_at
      - current_step
      - overdue
      - due_soon
      - can_act
      - can_reject
      - can_withdraw
      - steps
      - decisions
      - facts
      - author_left
      - web_url
      properties:
        id:
          type: integer
        request_id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - approved
          - rejected
          - withdrawn
          - overridden
        workflow:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
        requested_by:
          "$ref": "#/components/schemas/ApprovalUser"
        requested_at:
          type: string
          format: date-time
        current_step:
          type: object
          required:
          - index
          - total
          properties:
            index:
              type: integer
            total:
              type: integer
              nullable: true
            role:
              type: string
              nullable: true
            label:
              type: string
              nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
        due_soon:
          type: boolean
        can_act:
          type: boolean
          description: Whether the caller can approve the current step now — never
            on their own request (another approver decides it), unless they are a
            company admin, and never on a token without `write:broadcasts`.
        can_reject:
          type: boolean
          description: "`can_act`, and the token carries rejecting's delete-level
            scope (`destructive:broadcasts`; an alert's, `destructive:safety_hub`)."
        can_withdraw:
          type: boolean
          description: 'Whether `POST /approvals/{id}/withdraw` takes it back for
            the caller now: it is pending, the caller asked for it or is a company
            admin (while Approvals is off, only the person who asked), and the token
            carries `write:broadcasts` and the delete-level scope.'
        content_version:
          type: integer
          nullable: true
          description: The message's version as Unix seconds — send it back as `seen`
            on approve/reject, which then refuses (409 `content_changed`) if the message
            changed since it was read.
        steps:
          type: array
          description: Every step of the workflow, in order.
          items:
            type: object
            properties:
              index:
                type: integer
              label:
                type: string
                nullable: true
              approver:
                type: string
                description: Who decides it, as the workflow editor names it ("Anyone
                  with the manager role").
        waiting_on:
          type: string
          nullable: true
          description: Who it waits on — never the person who asked; null once it
            waits on nobody (decided, withdrawn, or its message was sent or canceled).
        author_left:
          type: boolean
          description: Its author has left the company, so approving it won't send
            it.
        superseded_by_id:
          type: integer
          nullable: true
          description: 'It was edited after it was sent for review, so its review
            started again: decide this request instead.'
        decisions:
          type: array
          description: The audit trail, oldest first.
          items:
            type: object
            properties:
              decision:
                type: string
                description: approve, reject, withdraw or sent_without_approval.
              step_index:
                type: integer
                nullable: true
              role:
                type: string
                nullable: true
                description: The role the decider acted as; null for a withdrawal
                  or a send without approval.
              by:
                type: object
                properties:
                  id:
                    type: integer
                    nullable: true
                  name:
                    type: string
                    description: A name, "Deleted user" once they are erased, or "System".
              notes:
                type: string
                nullable: true
              decided_at:
                type: string
                format: date-time
                nullable: true
        heading:
          type: string
          nullable: true
          description: What the message section is called in the request's tense —
            "What you're approving", "What's under review", "What was approved" …
        consequence:
          type: string
          nullable: true
          description: 'While it waits: what approving does — who it is released to,
            and when. Null once it is decided.'
        facts:
          type: array
          description: Who receives it, through which channels and when, and the rest
            the web page lists.
          items:
            type: object
            required:
            - label
            - value
            properties:
              label:
                type: string
              value:
                type: string
              hint:
                type: string
                nullable: true
              tone:
                type: string
                nullable: true
                enum:
                - warning
                - danger
                -
                description: "`danger` — a problem with a send still waiting (nobody
                  would receive it); never once the request is decided or the message
                  is out or canceled."
        web_url:
          type: string
          description: The same request on the web.
        communication:
          allOf:
          - "$ref": "#/components/schemas/CommsHubApprovalCommunication"
          nullable: true
          description: The message as its readers will see it; null when it has been
            deleted.
    CommsHubApprovalCommunicationSummary:
      type: object
      required:
      - type
      - type_label
      - source_type
      - source_id
      - status
      properties:
        type:
          type: string
          enum:
          - post
          - broadcast
          - newsletter
          - emergency_alert
          - survey
          - contest
          - triggered_message
          - message
          description: 'What kind of message it is — a stable key to switch on. It
            never follows a rename of the words: an automated message (a trigger''s)
            is `triggered_message`. `message` is any other kind.'
        type_label:
          type: string
          example: Feed post
          description: The kind's name as the pages say it ("Feed post", "Automated
            message", "Newsletter issue"). Words to show, not to compare — they change
            with the product's copy; switch on `type`.
        source_type:
          type: string
          example: NewsFeed::Feed
        source_id:
          type: integer
        title:
          type: string
          nullable: true
        status:
          type: string
          description: The message's own status (draft, scheduled, published, canceled
            …).
    CommsHubApprovalCommunication:
      allOf:
      - "$ref": "#/components/schemas/CommsHubApprovalCommunicationSummary"
      - type: object
        description: 'The fields its kind adds. A post: the `GET /feeds/{id}` names;
          a broadcast: the `GET /broadcasts/{id}` names; a newsletter issue: its preview
          and where to read it.'
        properties:
          kind:
            type: string
            description: Post and broadcast — post, announcement, must_read, break_through
              or in_context.
          kind_label:
            type: string
            nullable: true
            description: Post and broadcast — the reader's word for the kind (the
              company's own label when it chose one).
          content_type:
            type: string
            description: Post — update, question or poll.
          priority:
            type: string
            description: Post — social, operational or must_read.
          must_read:
            type: boolean
          requires_acknowledgement:
            type: boolean
            description: Post — readers must acknowledge it.
          body:
            type: string
            nullable: true
            description: Post — the words as written, in Markdown (the pictures in
              them taken out when `pictures_shown` is false).
          rendered_body:
            type: string
            nullable: true
            description: Post — the body as HTML, its pictures at storage URLs signed
              now (left out when `pictures_shown` is false).
          inline_media_ids:
            type: array
            items:
              type: integer
            description: Post — the pictures the text shows; leave them out of the
              gallery. Empty when `pictures_shown` is false.
          topics:
            type: array
            items:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
          author:
            "$ref": "#/components/schemas/ApprovalUser"
          scheduled_at:
            type: string
            format: date-time
            nullable: true
          expires_at:
            type: string
            format: date-time
            nullable: true
          published_at:
            type: string
            format: date-time
            nullable: true
          pictures_shown:
            type: boolean
            description: 'Post — false for someone who may open the request but neither
              decides nor decided it and may not read the post: `media` and `inline_media_ids`
              are empty, `cover_image` null, and `body` and `rendered_body` have no
              pictures.'
          pictures_withheld:
            type: boolean
            description: Post — true when `pictures_shown` is false and the post has
              pictures or files that were left out (a text-only post answers false).
              The web draws "Its pictures and files load for the people deciding it."
              on it.
          media:
            type: array
            description: Post — its pictures and files, URLs signed for an hour.
            items:
              "$ref": "#/components/schemas/NewsFeedFeedMedia"
          cover_image:
            type: object
            nullable: true
            description: Post — drawn at the top of the post and left out of the gallery.
            properties:
              media_id:
                type: integer
              url:
                type: string
              alt:
                type: string
                nullable: true
              width:
                type: integer
                nullable: true
              height:
                type: integer
                nullable: true
              link:
                type: string
                nullable: true
                description: A "Tell people" post's cover opens the record it announces
                  (the course, wiki or Sites page) — the same in-app path as the post's
                  own link in `body`. Null on every other post.
          poll:
            allOf:
            - "$ref": "#/components/schemas/NewsFeedPoll"
            nullable: true
            description: A poll — its choices; draw them read-only.
          description:
            type: string
            nullable: true
            description: Broadcast — its message, mentions as names.
          is_critical:
            type: boolean
          require_acknowledgment:
            type: boolean
          channels:
            type: array
            items:
              type: string
            description: Broadcast — in_app, email, push, sms, voice.
          delivery_mode:
            type: string
            description: Broadcast — immediate, on_shift_only or next_shift_start.
          hero_image:
            type: object
            nullable: true
            description: Broadcast — its cover picture, signed for an hour.
            properties:
              url:
                type: string
              alt:
                type: string
              width:
                type: integer
                nullable: true
              height:
                type: integer
                nullable: true
          attachments:
            type: array
            description: Broadcast — its files, signed for an hour.
            items:
              type: object
              properties:
                id:
                  type: integer
                filename:
                  type: string
                content_type:
                  type: string
                byte_size:
                  type: integer
                url:
                  type: string
          preview_text:
            type: string
            nullable: true
            description: Newsletter — the line under its subject.
          publication:
            type: object
            description: Newsletter — the publication it is an issue of.
            properties:
              id:
                type: integer
              name:
                type: string
                nullable: true
          blocks_count:
            type: integer
          web_url:
            type: string
            description: Newsletter — the request's web page, which draws the issue
              whole.
    DelegatedReview:
      type: object
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - post
          - broadcast
        message_kind:
          type: string
          enum:
          - post
          - announcement
          - must_read
          - break_through
          - in_context
        message_kind_label:
          type: string
        title:
          type: string
        preview:
          type: string
          description: First 280 characters of the message as plain text.
        body:
          type: string
          description: The whole message as plain text.
        delegate:
          type: object
          nullable: true
          description: The delegate who wrote it; null when an app drafted it.
          properties:
            id:
              type: integer
            name:
              type: string
        written_by:
          type: string
          nullable: true
          description: The delegate's name, or the app that drafted it.
        submitted_at:
          type: string
          format: date-time
        send_at:
          type: string
          format: date-time
          nullable: true
          description: The send time the delegate picked; null to send on approval.
        approval_step:
          type: boolean
          description: Approving hands it to the company's reviewers instead of sending
            it.
        can_decide:
          type: boolean
          description: The token may approve or decline it (write:news_feed for a
            post, write:broadcasts for a broadcast).
        seen:
          type: integer
          description: Send back as `seen` on approve.
        approve_path:
          type: string
        decline_path:
          type: string
        review_path:
          type: string
          description: The web review page.
    CompanyStoreViewer:
      type: object
      description: Who is asking. THE only place role appears in a Company Store payload.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
          description: Absolute avatar URL, or null.
        is_manager:
          type: boolean
          description: 'Has a redemption approval queue — a designated approver-group
            member, or a line manager who has not been superseded by one. This is
            the ONLY difference between the design''s Employee and Manager personas
            ("everything above, plus approves orders"), and it is about the queue,
            not the job title: a `manager`-role user with no direct reports is `false`.
            On the dashboard it also decides whether `team_approvals` is present;
            on the catalog it changes nothing.'
        is_store_admin:
          type: boolean
          description: A business admin-or-above, or a Company Store app-admin. Offer
            the admin surfaces the web nav offers the same people. No catalog or dashboard
            field differs for them.
    CompanyStoreFeatures:
      type: object
      description: The TENANT switches that decide which surfaces exist at all. Hide
        a section on `features`, disable a control on `viewer`.
      properties:
        points_redemption_enabled:
          type: boolean
          description: Defaults to ON.
        cash_purchases_enabled:
          type: boolean
          description: Defaults to OFF — a tenant that never opted in must not be
            shown card checkout.
        mixed_payments_enabled:
          type: boolean
          description: Defaults to OFF.
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
          description: True when the tenant has at least one active region.
        points_per_dollar:
          type: integer
          description: Conversion rate for a split-payment slider. Coerced to a positive
            integer server-side.
        grid_limit:
          type: integer
          description: How many cards each fixed-size grid holds (the tenant's `featured_items_count`,
            clamped), so a carousel can size itself to what it will receive. Present
            on the DASHBOARD only — a paginated screen reports `meta.per_page` instead,
            so promising a grid size there would be a number with nothing to size.
    CompanyStoreRegion:
      type: object
      nullable: true
      description: The region this payload was scoped to. Null when regions are off
        — and also when they are on but this caller resolves to none, in which case
        the pool narrowed to global items only. `features.regions_enabled` distinguishes
        the two.
      properties:
        id:
          type: integer
        name:
          type: string
        country_code:
          type: string
    CompanyStoreBalance:
      type: object
      description: The caller's wallet.
      properties:
        points_balance:
          type: integer
        pending_points:
          type: integer
        lifetime_points_earned:
          type: integer
        lifetime_points_spent:
          type: integer
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
    CompanyStoreContext:
      type: object
      description: The blocks every catalog response reports. Identical for every
        role — read `viewer` for who is asking and `features` for what the tenant
        switched on.
      properties:
        viewer:
          "$ref": "#/components/schemas/CompanyStoreViewer"
        features:
          "$ref": "#/components/schemas/CompanyStoreFeatures"
        region:
          "$ref": "#/components/schemas/CompanyStoreRegion"
        available_regions:
          type: array
          description: Every active region, for a client's own picker. Empty when
            regions are off. The web only renders a switcher when there is more than
            one.
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
              country_code:
                type: string
              active:
                type: boolean
                description: True for the region this payload was scoped to.
        balance:
          "$ref": "#/components/schemas/CompanyStoreBalance"
        redemption:
          type: object
          properties:
            approval_threshold_points:
              type: integer
              nullable: true
              description: 'The points figure AT OR ABOVE which a redemption is HELD
                for approval instead of spending immediately — the lowest enabled
                tier. Inclusive: the checkout service compares `>=`, so an item priced
                exactly at this figure IS held, and `requires_approval` on an item
                detail agrees. Null when the tenant has no approval tier on. Disclose
                it BEFORE a one-tap redeem: that flow has nowhere else to say so.'
    CompanyStoreFilters:
      type: object
      description: The whole filter sheet — what is applied, and every alternative.
      properties:
        applied:
          type: object
          description: What the server actually applied. An unrecognised `category`
            / `sort` / `collection` is dropped, and this block is how a client sees
            that.
          properties:
            category:
              type: string
              nullable: true
            collection:
              type: string
              nullable: true
            featured:
              type: boolean
            within_balance:
              type: boolean
            search:
              type: string
              nullable: true
            sort:
              type: string
            region_id:
              type: integer
              nullable: true
        sort_options:
          type: array
          description: The same six strategies the desktop sort dropdown offers, in
            its order.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - featured
                - popular
                - price_low
                - price_high
                - name
                - newest
              label:
                type: string
              active:
                type: boolean
        categories:
          type: array
          description: '"All Categories" first with the true total, then one row per
            ENABLED category — INCLUDING rows at 0, so the sheet does not reshuffle
            as stock moves. Counts apply region, audience, category enablement and
            `search`. They do NOT apply the active category (each row is an ALTERNATIVE
            to it), and they do not apply the active collection or `within_balance`
            either: this sheet is shared with `GET /company-store/catalog/categories`.
            The web and phone pills do count under the collection and "Within my balance";
            the listing returns those counts in `filter_counts`. Use `has_items` to
            hide empty rows the way the web dropdown does.'
          items:
            "$ref": "#/components/schemas/CompanyStoreCategoryRow"
        collections:
          type: array
          description: 'Merchandised collection chips, busiest first. Only collections
            that actually hold an item appear — unlike categories these are free-form
            per-tenant tags, so there is no fixed vocabulary to render an empty row
            for. Counted like `categories`: not under `within_balance`.'
          items:
            "$ref": "#/components/schemas/CompanyStoreCollectionChip"
    CompanyStoreCategoryRow:
      type: object
      description: One row of the category filter sheet.
      properties:
        key:
          type: string
          nullable: true
          description: Null on the "All Categories" row.
        label:
          type: string
          description: The storefront's merchandising label — "Donate", not "Charitable";
            "Gift Cards", not "Gift Card".
        icon:
          type: string
          description: Font Awesome name, the same glyph the web chip draws.
        color:
          type: string
          description: Bootstrap colour token, the same one the web chip uses.
        count:
          type: integer
        has_items:
          type: boolean
        active:
          type: boolean
    CompanyStoreCollectionChip:
      type: object
      description: One merchandised collection chip.
      properties:
        key:
          type: string
        label:
          type: string
        count:
          type: integer
        active:
          type: boolean
    CompanyStorePageMeta:
      type: object
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreItemCard:
      type: object
      description: |-
        One item as every Company Store grid renders it. IDENTICAL for every role — an employee, a manager and a store admin get the same fields with the same values for the same request. What varies per caller is the affordability block, `wishlisted`, `can_quick_redeem` and which items appear at all (audience + region).
        **Presentation extras are CATALOG-ONLY.** `category_label`, `category_icon`, `category_color`, `stock_label`, `stock_detail_label`, `has_variants`, `variant_types`, `digital_delivery`, `personalizable`, `wishlisted` and `can_quick_redeem` are added by the catalog endpoints on top of the shared card, because the catalog is the screen that renders chips, stock copy and a one-tap redeem button. The dashboard's `within_reach`, `featured_items` and `saved_items.items` carry every OTHER field here — identity, image, status, availability, region, the payment-gated prices and the affordability block — and omit the eleven above. Treat them as optional rather than assuming a grid card has them.
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          enum:
          - swag
          - gift_card
          - experience
          - charitable
        category_label:
          type: string
          description: The storefront's merchandising label ("Donate", "Gift Cards").
            Catalog endpoints only.
        category_icon:
          type: string
          description: Catalog endpoints only.
        category_color:
          type: string
          description: Catalog endpoints only.
        collection:
          type: string
          nullable: true
        image_url:
          type: string
          nullable: true
          description: Absolute URL. Prefers an Asset Library image, then an upload,
            then a provider catalog URL.
        featured:
          type: boolean
        status:
          type: string
          enum:
          - active
          - out_of_stock
          - discontinued
          - draft
          - coming_soon
        status_label:
          type: string
        available:
          type: boolean
          description: Active AND in stock. Everything buyable is gated on this.
        low_stock:
          type: boolean
        inventory_count:
          type: integer
          nullable: true
          description: Null means unlimited (print-on-demand, gift cards).
        stock_label:
          type: string
          description: '"In stock" / "Low stock" when the item is available. When
            it is not, the copy names the actual state rather than always saying out
            of stock: "Out of stock" for a stocked-out item, "Not yet published" for
            `draft` / `coming_soon`, "No longer available" for `discontinued` — because
            promising a restock that is never coming is worse than saying nothing.
            Same three-way split the web detail page renders. Catalog endpoints only.'
        stock_detail_label:
          type: string
          description: The count-carrying form — "Low stock (4 left)". Two strings
            rather than one so a card and a detail screen can differ without either
            client building copy out of a count. Catalog endpoints only.
        requires_shipping:
          type: boolean
          description: Admin-editable per item; falls back to the category default
            (only swag ships).
        digital_delivery:
          type: boolean
          description: Catalog endpoints only.
        personalizable:
          type: boolean
          description: Engraved per person — the engraving details are collected at
            checkout. Catalog endpoints only.
        has_variants:
          type: boolean
          description: Catalog endpoints only.
        variant_types:
          type: array
          items:
            type: string
          description: The variant types a client must collect before checkout. Empty
            when there are none. Catalog endpoints only.
        region:
          type: object
          nullable: true
          description: Null for a global item.
          properties:
            id:
              type: integer
            name:
              type: string
        points_price:
          type: integer
          nullable: true
          description: Present only while points redemption is ON and the item carries
            a points price. Null is a gate, not a missing value.
        cash_price:
          type: number
          format: float
          nullable: true
          description: Present only while card purchases are ON and the item carries
            a cash price.
        price_label:
          type: string
          description: The price cell's copy, under the same gates — "4500 pts", "4500
            pts or $45.00", "$45.00", or "Not currently redeemable" when no payment
            path is open.
        redeemable_with_points:
          type: boolean
        purchasable_with_cash:
          type: boolean
        affordable:
          type: boolean
          description: Against THIS caller's balance.
        points_to_go:
          type: integer
          nullable: true
          description: Shortfall in points. Null when there is no open points path.
        progress_percent:
          type: integer
          description: 0..100 for an affordability bar. 0 when there is no open points
            path.
        ready_to_redeem:
          type: boolean
          description: Affordable AND available. Both halves matter — a wishlist keeps
            out-of-stock items, which would otherwise read "ready".
        wishlisted:
          type: boolean
          description: Catalog endpoints only.
        can_quick_redeem:
          type: boolean
          description: 'The server would accept a one-tap redemption right now: points
            on, a points price, an enabled category, in stock, no variants, no shipping,
            no engraving, and affordable. Same guard set the redeem endpoint enforces.
            Catalog endpoints only.'
        url:
          type: string
          description: Absolute web URL for the item, so a client never hardcodes
            store paths.
    CompanyStoreItemDetail:
      allOf:
      - "$ref": "#/components/schemas/CompanyStoreItemCard"
      - type: object
        properties:
          large_image_url:
            type: string
            nullable: true
            description: Higher-resolution hero. Absolute.
          photos:
            type: array
            description: The primary image first, then the Asset Library gallery rows,
              in display order. Absolute URLs.
            items:
              type: object
              properties:
                url:
                  type: string
                alt:
                  type: string
          variants:
            type: array
            description: The variant pickers, in the item's own declared order.
            items:
              type: object
              properties:
                type:
                  type: string
                label:
                  type: string
                  description: Resolved server-side, so an unlabelled type reads "Size",
                    not "size".
                required:
                  type: boolean
                options:
                  type: array
                  items:
                    type: string
          item_details:
            type: object
            description: The web detail page's "Item Details" list.
            properties:
              category:
                type: string
              category_label:
                type: string
              sku:
                type: string
                nullable: true
              provider_type:
                type: string
              provider_label:
                type: string
              requires_shipping:
                type: boolean
              estimated_delivery_days:
                type: integer
                nullable: true
              delivery_label:
                type: string
                nullable: true
                description: '"5 business days" / "Shipping required" for a shipped
                  item, "Instant digital delivery" for a gift card, null when neither
                  applies.'
              fair_market_value:
                type: number
                format: float
                nullable: true
                description: The figure a tax statement reports against a redemption.
          payment_options:
            type: array
            description: One entry per path the ITEM has a price for. A method the
              item cannot be bought with at all is omitted; a method the TENANT switched
              off is present with a reason.
            items:
              type: object
              properties:
                key:
                  type: string
                  enum:
                  - points
                  - cash
                  - mixed
                label:
                  type: string
                points_required:
                  type: integer
                  nullable: true
                cash_required:
                  type: number
                  format: float
                  nullable: true
                available:
                  type: boolean
                block_reason:
                  type: string
                  nullable: true
                  description: Why not, in the caller's words — "Insufficient points",
                    "Card purchases aren't enabled for this store", "Split payments
                    aren't enabled for this store", the item's own unavailable copy
                    ("Out of stock", or "Not yet published" / "No longer available"
                    for a `draft`/`coming_soon` / `discontinued` item — same wording
                    as `stock_label`), or the neutral "This item isn't available for
                    redemption right now." for a switched-off category (deliberately
                    the same copy the audience refusal uses, so neither discloses
                    the other).
          requires_approval:
            type: boolean
            description: Would redeeming this item's points price be HELD for approval
              — `points_price >= redemption.approval_threshold_points`, the same inclusive
              comparison the checkout service makes. False when no tier is enabled.
          category_enabled:
            type: boolean
            description: False for a deep link into a category the admin switched
              off. The item still renders — the web page does too — but nothing about
              it is buyable.
          restock_watch:
            type: boolean
            description: Whether the caller has a back-in-stock ("notify me") watch
              on this item.
          related_items:
            type: array
            description: Up to four more from the same category, under the same region
              and audience filters as the grid — an item whose own page would refuse
              must not appear here either.
            items:
              "$ref": "#/components/schemas/CompanyStoreItemCard"
    CompanyStoreOrderRow:
      type: object
      description: 'One order row. `item` is nil-tolerant: an order whose item row
        has since been deleted still renders, with `item_name` falling back to "Item".'
      properties:
        id:
          type: integer
        order_number:
          type: string
        item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
        item_name:
          type: string
        quantity:
          type: integer
        status:
          type: string
        status_label:
          type: string
          description: Through the canonical helper, so `pending_approval` reads "Awaiting
            Approval" here exactly as it does on every other store surface.
        points_spent:
          type: integer
        cash_amount:
          type: number
          format: float
        total_label:
          type: string
        placed_at:
          type: string
          format: date-time
        url:
          type: string
    CompanyStoreApprovalRequest:
      type: object
      description: One row of the manager Team Approvals widget, oldest-waiting first.
      properties:
        id:
          type: integer
        order_number:
          type: string
        requester:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            title:
              type: string
              nullable: true
            image:
              type: string
              nullable: true
        item_name:
          type: string
        points:
          type: integer
        quantity:
          type: integer
        waiting_since:
          type: string
          format: date-time
          description: What the widget sorts by — a manager is holding these up.
        status:
          type: string
        status_label:
          type: string
        can_approve:
          type: boolean
          description: 'True for every row in this list when the caller''s token holds
            `write:company_store` (the approve endpoint asks it; a read-only token
            gets `false`): the queue IS the approvable set for this viewer. The decision
            endpoints still enforce the rule on write, which is where a tier change
            under a stale list is caught. A session always passes the scope half.'
        can_decline:
          type: boolean
          description: Whether the caller's token holds `destructive:company_store`,
            which the decline endpoint asks (a decline is a final act). Draw Decline
            off this flag and Approve off `can_approve`. A session always passes.
    CompanyStoreError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            details:
              type: object
              nullable: true
    CompanyStoreApprovalQueue:
      type: object
      properties:
        viewer:
          type: object
          description: Who is asking. `is_manager` is always `true` in a 200 here
            (the endpoint 403s otherwise) and is reported so one client model covers
            every screen in this namespace.
          properties:
            id:
              type: integer
            name:
              type: string
            image:
              type: string
              nullable: true
            is_manager:
              type: boolean
              example: true
            is_store_admin:
              type: boolean
              description: 'A business admin-or-above or a Company Store app admin.
                On THIS endpoint it is load-bearing: it is what widens the pool to
                the whole tenant at every approval tier (bounded by the token — a
                narrowed `own_company_store` token reads the manager queue even for
                an admin, so a `true` here does not by itself prove the wide pool
                was served; `scope` does).'
        features:
          type: object
          description: The TENANT switches, the same block the dashboard / catalog
            / orders endpoints report. Present so a client renders one store shell
            from any call.
          properties:
            points_redemption_enabled:
              type: boolean
            cash_purchases_enabled:
              type: boolean
            mixed_payments_enabled:
              type: boolean
            recognition_integration_enabled:
              type: boolean
            regions_enabled:
              type: boolean
            points_per_dollar:
              type: integer
              example: 100
        scope:
          type: string
          enum:
          - company
          - team
          description: '`company` — a store admin (every tier) or a designated approver-group
            member (manager tier); the queue spans the whole tenant. `team` — a line
            manager; the queue is their own direct reports''. Drives the screen''s
            subtitle: calling a committee''s rows "your team" mislabels every one
            of them.'
        filters:
          type: object
          description: What was actually APPLIED, after clamping and fallbacks.
          properties:
            sort:
              type: string
              enum:
              - oldest
              - newest
            per_page:
              type: integer
              example: 20
        total_pending:
          type: integer
          description: The FULL queue depth — how many redemptions are waiting on
            this approver in total, independent of `page` / `per_page`. This is the
            tab badge figure, and it equals the dashboard widget's `team_approvals.pending_count`.
          example: 7
        items:
          type: array
          items:
            "$ref": "#/components/schemas/CompanyStoreApprovalQueueRow"
        meta:
          "$ref": "#/components/schemas/CompanyStoreApprovalPageMeta"
    CompanyStoreApprovalQueueRow:
      type: object
      description: One held redemption as the Approvals SCREEN renders it — the shared
        `CompanyStoreApprovalRequest` card plus the six fields a full-width queue
        row shows and a four-row dashboard widget doesn't.
      properties:
        id:
          type: integer
          description: The StoreOrder id — what the decision endpoints take.
        order_number:
          type: string
          example: ORD-20260731-1AOOOK
        requester:
          type: object
          description: The employee whose redemption is held.
          properties:
            id:
              type: integer
            name:
              type: string
              example: Maya Chen
            title:
              type: string
              nullable: true
              example: Support Specialist
            image:
              type: string
              nullable: true
              description: Absolute avatar URL.
        requester_department:
          type: string
          nullable: true
          description: The requester's organizational department — the design's "Support
            · Jul 18" sub-line. `null` for a user with no department; the client then
            prints the date alone.
          example: Support
        item_name:
          type: string
          description: Every line of the order ("Mug, Hoodie ×2"), not only the representative
            one.
        item:
          type: object
          nullable: true
          description: "`null` when the item row has been deleted."
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
              description: Absolute URL.
        points:
          type: integer
          description: The points held on this redemption — what a decline returns.
          example: 4500
        points_label:
          type: string
          description: Pre-formatted with the same delimiter the web table uses, so
            the surfaces never disagree.
          example: 4,500 pts
        quantity:
          type: integer
          example: 1
        variant_display:
          type: string
          nullable: true
          description: '`"Size: L, Color: Black"`. `null` when the item has no variants.'
        waiting_since:
          type: string
          format: date-time
          description: When the redemption was placed — what `sort` orders by.
        waiting_days:
          type: integer
          description: Whole days waited, computed server-side so every client's "3
            days" agrees with every other's.
          example: 3
        status:
          type: string
          example: pending_approval
        status_label:
          type: string
          description: The canonical store copy, via the same helper every other store
            surface uses — `"Awaiting Approval"`, never `"Pending Approval"`.
          example: Awaiting Approval
        can_approve:
          type: boolean
          description: 'May THIS caller decide THIS row. `true` for every row on the
            manager branch, where the queue is the approvable set. On the admin branch
            the pool is wider than the approvable set, so it can be `false`: a Company
            Store app admin who is not a business admin sees an admin-tier hold they
            may not release — the same row the web admin page draws with its buttons
            disabled — and a business admin''s OWN hold while another business admin
            exists (nobody signs off their own redemption while someone else can).
            Render the controls off this flag; a decision POST on a `false` row answers
            `403 not_an_approver`. It is also `false` for a token without `write:company_store`,
            which the approve endpoint asks (a read-only token may list the queue
            but not decide it). The decision endpoints enforce the rule on write regardless,
            which is where a tier change under a stale list is caught.'
        can_decline:
          type: boolean
          description: 'May THIS caller decline THIS row: they may decide it (the
            rule behind `can_approve`) AND the token holds `destructive:company_store`,
            which the decline endpoint asks (a decline is a final act). Draw Decline
            off this flag and Approve off `can_approve` — a write-only token may approve
            but its decline answers `403 insufficient_permissions`. A session always
            passes the scope half.'
        order_url:
          type: string
          description: Absolute link to where THIS caller decides the hold — the manager
            approval queue when the order is in their queue there, else the order's
            admin page. (The employee's own order page refused every approver but
            a store admin.)
        hold_reason:
          type: string
          nullable: true
          description: Why it is held — the velocity rule that tripped, else "Over
            the manager approval threshold" / "Over the admin approval threshold".
    CompanyStoreApprovalDecisionResponse:
      type: object
      properties:
        approval:
          allOf:
          - "$ref": "#/components/schemas/CompanyStoreApprovalRequest"
          - type: object
            properties:
              decision:
                type: string
                enum:
                - approved
                - declined
              decided_at:
                type: string
                format: date-time
              decided_by:
                type: object
                description: The approver who took the decision.
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  title:
                    type: string
                    nullable: true
                  image:
                    type: string
                    nullable: true
              reason:
                type: string
                nullable: true
                description: The decline reason as recorded. `null` on an approve,
                  and on a decline sent without one.
              points_refunded:
                type: integer
                description: What went back to the employee's wallet. The held points
                  on a decline; `0` on an approve, where the points stay spent.
                example: 4500
              status:
                type: string
                description: The RESULTING order status — `pending` after an approve
                  (now queued for fulfillment), `cancelled` after a decline.
                example: pending
              can_approve:
                type: boolean
                description: "`false` — the row has left the queue."
              can_decline:
                type: boolean
                description: "`false` — the row has left the queue."
        total_pending:
          type: integer
          description: The queue depth AFTER this decision, so a client's tab badge
            updates from this response.
        message:
          type: string
          description: The same copy the web flash shows, so both surfaces tell an
            approver the same thing.
          example: Approved ORD-20260731-1AOOOK — released for fulfillment.
        unread_notification_count:
          type: integer
    CompanyStoreApprovalPageMeta:
      type: object
      description: The page envelope every paginated endpoint in this namespace answers
        with. `total_count` equals `total_pending` here, because nothing narrows the
        queue.
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
        total_pages:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreApprovalError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - store_disabled
              - approvals_delegated
              - not_an_approver
              - not_found
              - decision_failed
              - already_decided
            message:
              type: string
    CompanyStoreCartLine:
      type: object
      properties:
        id:
          type: integer
        item:
          type: object
          description: The item card
          as the catalog renders it.:
        quantity:
          type: integer
        max_quantity:
          type: integer
          description: min(5, stock) — the stepper's ceiling.
        selected_variants:
          type: object
          additionalProperties:
            type: string
        unit_points:
          type: integer
        unit_cash:
          type: number
        line_points:
          type: integer
        line_cash:
          type: number
        issue:
          type: string
          nullable: true
          description: Why this line can't check out as it stands; null when it can.
    CompanyStoreCartResponse:
      type: object
      properties:
        cart:
          type: object
          properties:
            id:
              type: integer
              nullable: true
            line_count:
              type: integer
            units:
              type: integer
            fulfillment_route:
              type: string
              nullable: true
              enum:
              - printful
              - managed
              - internal
            requires_shipping:
              type: boolean
            currency_code:
              type: string
              nullable: true
            limits:
              type: object
              properties:
                max_lines:
                  type: integer
                  example: 5
                max_quantity_per_line:
                  type: integer
                  example: 5
            lines:
              type: array
              items:
                "$ref": "#/components/schemas/CompanyStoreCartLine"
            totals:
              type: object
              properties:
                points:
                  type: integer
                cash:
                  type: number
                cash_cents:
                  type: integer
            blockers:
              type: array
              items:
                type: string
              description: Why a points checkout would be refused, in the buyer's
                words. Empty when it wouldn't.
        wallet:
          type: object
          description: The caller's balance card.
        added_line_id:
          type: integer
          description: POST /cart/lines only.
        warnings:
          type: array
          items:
            type: string
    CompanyStoreCartError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - unavailable
              - ineligible
              - route_mismatch
              - currency_mismatch
              - cart_full
              - out_of_stock
              - variant_invalid
              - region
              - line_not_found
            message:
              type: string
              description: The web's own sentence.
    CompanyStoreCartCheckoutPreview:
      type: object
      properties:
        checkout:
          type: object
          properties:
            lines:
              type: array
              items:
                "$ref": "#/components/schemas/CompanyStoreCartLine"
            totals:
              type: object
              properties:
                points:
                  type: integer
                cash:
                  type: number
                cash_cents:
                  type: integer
                units:
                  type: integer
            currency:
              type: string
              nullable: true
            payment_options:
              type: array
              items:
                type: object
                properties:
                  key:
                    type: string
                    enum:
                    - points
                    - cash
                    - mixed
                  label:
                    type: string
                  available:
                    type: boolean
                  block_reason:
                    type: string
                  amount:
                    type: object
                    description: "`{ points }`, `{ cash }` or `{ points_max }` (the
                      split slider's ceiling, already held under the balance, the
                      monthly redemption cap and the team store budget)."
            selected_payment:
              type: string
              nullable: true
            can_checkout:
              type: boolean
            shortfall:
              type: integer
              description: Points short of the total; 0 when covered.
            wallet:
              type: object
            shipping:
              type: object
              properties:
                required:
                  type: boolean
                collected_on_stripe_for:
                  type: array
                  items:
                    type: string
                  description: Payment types for which Stripe collects the address
                    (skip your form for these).
                prefill:
                  type: object
                countries_url:
                  type: string
            disclosures:
              type: object
              properties:
                required_approver:
                  type: string
                  nullable: true
                  description: Who the order is held for ("admin" for a velocity hold,
                    which always goes to an admin). Only a full-points order is ever
                    held — a points + card split goes to Stripe — so this is present
                    only while the `points` option is available.
                velocity_hold_reason:
                  type: string
                  nullable: true
                  description: Present only while the `points` option is available,
                    like `required_approver`.
                team_budget:
                  type: object
                  nullable: true
                  description: The team store budget that governs this employee's
                    points checkout (null when none does); `over` is true when this
                    cart's points exceed what is left, which the checkout will refuse.
                  properties:
                    name:
                      type: string
                    remaining:
                      type: integer
                    over:
                      type: boolean
    CompanyStoreCartCheckoutResponse:
      type: object
      properties:
        placed:
          type: boolean
        replayed:
          type: boolean
          description: 'True when `idempotency_key` had already placed this order:
            nothing was written now and the cart was left as it is. When `placed`,
            the `message` says it was already placed — never celebrate it as new.'
        replay_status:
          type: string
          enum:
          - placed
          - cancelled
          - declined
          description: 'Present on a replay answered `placed: true` — what became
            of the order `idempotency_key` placed. `placed`: it stands (whatever its
            progress). `cancelled`: it was cancelled since (an abandoned card payment,
            the buyer, an admin). `declined`: its approver turned it down. Nothing
            was placed now in any of them, and nothing is owed; for `cancelled` and
            `declined` show the `message` (it says so and how to start again) and
            never a success screen.'
          example: cancelled
        order:
          type: object
          description: The order card.
        held_for_approval:
          type: boolean
        payment:
          type: object
          description: Present only when `placed` is false.
          properties:
            checkout_url:
              type: string
            session_id:
              type: string
            return_url_prefix:
              type: string
        wallet:
          type: object
        message:
          type: string
    CompanyStoreOrderRequest:
      type: object
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - question
          - cancellation
          - delivery_status
          - address_change
          - change_selection
          - damaged
          - code_issue
          - payment
          - other
        kind_label:
          type: string
        status:
          type: string
          enum:
          - open
          - approved
          - declined
          - answered
          - withdrawn
          description: "`withdrawn` — the sender took it back while it was open; nobody
            decided it."
        message:
          type: string
        resolution_notes:
          type: string
          nullable: true
          description: The admin's reply or reason, once decided.
        created_at:
          type: string
          format: date-time
        processed_at:
          type: string
          format: date-time
          nullable: true
        withdraw_url:
          type: string
          nullable: true
          description: 'Present only on the caller''s OWN open request: POST it to
            take the request back (POST /company-store/order-requests/{id}/withdraw).'
    CompanyStoreCheckoutRequest:
      type: object
      required:
      - payment_type
      properties:
        payment_type:
          type: string
          enum:
          - points
          - cash
          - mixed
          description: How to pay. `points` places the order in this request; `cash`
            and `mixed` return a Stripe URL. Re-checked against the tenant's toggles
            server-side.
        quantity:
          type: integer
          default: 1
          description: Clamped to 1..99, and bounded again by the item's inventory.
        points_to_use:
          type: integer
          description: "`mixed` only — how many points to put toward the price. **Forced
            to 0 for `cash`**, so a hidden slider cannot spend points on a card-only
            submit. If it covers the whole price the service routes the order to the
            points path and the response comes back `placed: true`."
        variants:
          type: object
          additionalProperties:
            type: string
          description: 'Required when the item declares options — `{"size": "L", "color":
            "Black"}`. Keys the item never declared are dropped. An incomplete selection
            is refused with `variants_required`.'
          example:
            size: L
            color: Black
        shipping_address:
          "$ref": "#/components/schemas/CompanyStoreCheckoutShippingAddress"
        personalization:
          type: object
          description: Engraving details, for a personalizable item.
          properties:
            recipient_name:
              type: string
            years_of_service:
              type: string
            engraving_line:
              type: string
        idempotency_key:
          type: string
          description: "**Send one.** A client-generated key (a UUID per checkout
            attempt). A repeat returns the same order rather than placing a second
            one and debiting the points twice. Scoped to the caller, so another user's
            key can never resolve to their order."
          example: 8f14e45f-ea0b-4c1e-9d8a-2b3c4d5e6f70
    CompanyStoreCheckoutShippingAddress:
      type: object
      description: Where it ships. Send when `shipping.required` is true AND `shipping.collected_by`
        is `app`; when it is `stripe`, Stripe collects the address on its own page
        and copies it back onto the order.
      properties:
        name:
          type: string
        street1:
          type: string
        street2:
          type: string
        city:
          type: string
        state:
          type: string
        zip:
          type: string
        country:
          type: string
        phone:
          type: string
    CompanyStoreCheckoutPaymentOption:
      type: object
      description: One payment path, and the reason it can't be taken right now. A
        path the item has no price for is omitted entirely; a path the TENANT switched
        off is present with a reason, because that is a state an admin can change.
      properties:
        key:
          type: string
          enum:
          - points
          - cash
          - mixed
        label:
          type: string
          example: Redeem with Points
        points_required:
          type: integer
          nullable: true
        cash_required:
          type: number
          nullable: true
        available:
          type: boolean
        block_reason:
          type: string
          nullable: true
          description: Buyer-facing reason this path is unavailable — show it instead
            of a silently greyed-out button. Null when `available`.
    CompanyStoreCheckoutTotals:
      type: object
      description: What each path costs at the requested quantity.
      properties:
        points:
          type: object
          properties:
            points:
              type: integer
            cash:
              type: number
              example: 0.0
        cash:
          type: object
          properties:
            points:
              type: integer
              example: 0
            cash:
              type: number
        mixed:
          type: object
          properties:
            max_points:
              type: integer
              description: 'The slider ceiling — `min(points needed to cover the whole
                price, the caller''s balance)`. Do not allow past it: there would
                be no card portion left, and the write would silently become a points
                redemption.'
            points_per_dollar:
              type: integer
              description: The tenant's conversion rate.
            requested_points:
              type: integer
              description: Echo of the `points_to_use` this preview was asked about.
            cash_after_points:
              type: number
              description: What the card is charged for `requested_points`.
    CompanyStoreCheckoutPreviewResponse:
      type: object
      properties:
        checkout:
          type: object
          properties:
            item:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
                image_url:
                  type: string
                  nullable: true
                points_price:
                  type: integer
                cash_price:
                  type: number
                currency:
                  type: string
                  example: USD
            quantity:
              type: object
              properties:
                selected:
                  type: integer
                min:
                  type: integer
                  example: 1
                max:
                  type: integer
                  description: Bounded by the item's tracked inventory when it tracks
                    any.
            payment_options:
              type: array
              items:
                "$ref": "#/components/schemas/CompanyStoreCheckoutPaymentOption"
            selected_payment:
              type: string
              nullable: true
              description: The path to open on — the caller's requested `payment_type`
                when this item can take it, otherwise the first available one.
            can_checkout:
              type: boolean
              description: Is ANY path available. False for an unbuyable item (still
                a 200).
            totals:
              "$ref": "#/components/schemas/CompanyStoreCheckoutTotals"
            wallet:
              "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
            variants:
              type: array
              description: The option pickers to render, in the item's declared order.
              items:
                type: object
                properties:
                  type:
                    type: string
                    example: size
                  label:
                    type: string
                    example: Size
                  required:
                    type: boolean
                  options:
                    type: array
                    items:
                      type: string
            shipping:
              type: object
              properties:
                required:
                  type: boolean
                collected_by:
                  type: string
                  enum:
                  - app
                  - stripe
                  description: "`stripe` when Stripe Tax is on for a shippable item
                    — Stripe collects the address on its own page so `automatic_tax`
                    can price destination tax, and your form must skip its address
                    step. Points-only orders never reach Stripe, so they always collect
                    in-app."
                collected_by_stripe_for:
                  type: array
                  description: Which payment paths Stripe collects for (`[]`, or cash+mixed).
                  items:
                    type: string
                fields:
                  type: array
                  items:
                    type: string
                regions:
                  type: object
                  description: |-
                    The two address dropdowns' CONTENTS — the shippable countries and, nested under each, its states/provinces. `fields` above names the boxes; this says what each one accepts, which is what stops a native form from free-texting a state the fulfillment path reads as a 2-letter `state_code`.

                    Present whenever `required` is true — including when `collected_by` is `stripe`, because that collector is per PAYMENT PATH (`collected_by_stripe_for` is only cash/mixed) and the points-only path on the same item always collects in-app. Absent on a digital item, which has no shipping section.

                    Byte-identical to the `shipping_countries` payload of `GET /company-store/shipping-countries` (one server-side builder feeds both), so the normal purchase flow needs no extra round trip — call that endpoint only to cache the list at launch or to render an address form outside a checkout. Read its documentation for the rule that matters: country submits the display NAME, state submits the 2-letter CODE.
                  properties:
                    countries:
                      type: array
                      description: Never empty. Render whatever length this holds
                        rather than assuming one entry.
                      items:
                        "$ref": "#/components/schemas/CompanyStoreShippingCountry"
                    default_country:
                      type: string
                      description: The country to select when the caller has expressed
                        no preference — always one of the rows' `value`s.
                      example: United States
                    prefill:
                      "$ref": "#/components/schemas/CompanyStoreShippingPrefill"
            personalization:
              type: object
              description: The engraving block. `required` is whether this item is
                engraved at all; the per-field flags below say which boxes the server
                actually insists on, so a form need not mark all three required.
              properties:
                required:
                  type: boolean
                fields:
                  type: array
                  description: 'One entry per engraving field, with the server''s
                    own rules. Enforce `max_length` in the form: the service refuses
                    past it (`checkout_failed`), and discovering that after the buyer
                    has committed is the whole reason it is disclosed here.'
                  items:
                    type: object
                    properties:
                      key:
                        type: string
                        example: recipient_name
                      label:
                        type: string
                        example: Recipient name
                      required:
                        type: boolean
                        description: Today only `recipient_name` is required.
                      max_length:
                        type: integer
                        example: 80
            category_enabled:
              type: boolean
              description: False when the admin has switched this item's category
                off. The item still renders here (with every `payment_options` entry
                blocked) so a deep link is not a dead end, but the write refuses with
                `item_unavailable`. Same field GET /company-store/catalog/{id} reports.
            disclosures:
              "$ref": "#/components/schemas/CompanyStoreCheckoutDisclosures"
    CompanyStoreCheckoutDisclosures:
      type: object
      description: What will happen when the buyer commits — said before they commit,
        which for a one-tap redeem is the only place it can be said.
      properties:
        requires_approval:
          type: boolean
          description: Will this redemption be HELD for approval instead of spending
            immediately.
        approver_tier:
          type: string
          nullable: true
          enum:
          - admin
          - manager
          -
          description: Which tier the hold routes to. Null when nothing holds it.
        velocity_hold_reason:
          type: string
          nullable: true
          description: The short-window fraud brake tripped. It does not block the
            order, it forces an admin hold — disclose it as "this may need approval",
            not as a refusal.
        team_budget:
          type: object
          nullable: true
          description: The team store budget that governs this employee's points (null
            when none does, or no points path is offered); `over` is true when this
            redemption's points exceed what is left.
          properties:
            name:
              type: string
            remaining:
              type: integer
            over:
              type: boolean
        applies_to:
          type: array
          description: 'Which payment paths the hold applies to: `["points"]` or `[]`.
            Only a full-points order is ever held — a pure-card checkout spends no
            points, and a points + card split is charged through Stripe and never
            held (a split whose points cover the whole price is placed as a points
            order).'
          items:
            type: string
        monthly_points_cap:
          type: integer
          nullable: true
        monthly_points_remaining:
          type: integer
          nullable: true
    CompanyStoreCheckoutWallet:
      type: object
      description: The caller's points wallet. On a write response this is the balance
        AFTER the debit (or, for an abandon, after the refund) — render from it rather
        than re-asking /config, which would show a stale number. Same six fields as
        `CompanyStoreBalance` in company_store.yaml — both are the namespace's one
        `balance_card`, so a client parses one shape.
      properties:
        points_balance:
          type: integer
        pending_points:
          type: integer
        lifetime_points_earned:
          type: integer
        lifetime_points_spent:
          type: integer
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
    CompanyStoreCheckoutPayment:
      type: object
      description: Present only when `placed` is false. What to open, and where to
        confirm.
      properties:
        provider:
          type: string
          enum:
          - stripe
        checkout_url:
          type: string
          description: Open this in the system browser (ASWebAuthenticationSession
            / Chrome Custom Tabs). Not a WebView you built — the buyer needs to see
            the real stripe.com address.
          example: https://checkout.stripe.com/c/pay/cs_test_a1B2c3
        session_id:
          type: string
          example: cs_test_a1B2c3
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Stripe's own session expiry (24h). After it, confirm reports
            the order's real state.
        currency:
          type: string
          example: USD
        cash_amount:
          type: number
          description: What the card will be charged.
        points_held:
          type: integer
          description: The points portion
          already debited.:
        return_url_prefix:
          type: string
          description: Watch the in-browser navigation for this PREFIX (Stripe appends
            its own `?session_id=`), then close the browser and call `complete_url`.
            Intercepting is an optimisation — polling `complete_url` works without
            it.
        cancel_url_prefix:
          type: string
          description: Same, for the buyer pressing Stripe's back link — then call
            `abandon_url`.
        complete_url:
          type: string
          description: Idempotent. Poll it.
        abandon_url:
          type: string
    CompanyStoreCheckoutWriteResponse:
      type: object
      description: The response to the write. **Branch on `placed`** — a `mixed` checkout
        the points fully covered comes back placed with no `payment` block.
      properties:
        placed:
          type: boolean
          description: |-
            True — the order is placed, nothing further is owed. False — open `payment.checkout_url`.
            `placed` is derived from the ORDER's own state, never from whether a `payment` block happens to be present, so `true` can always be trusted as "nothing is owed". A 200 therefore never carries `false` without a `payment` block: the one case where an order exists, money is owed and no session can be returned (an idempotent retry whose Stripe session expired or closed) is answered `409 checkout_session_unavailable` instead. A replay of an order cancelled since is `true` too (nothing is owed on it): its `replay_status` says it was cancelled.
        replayed:
          type: boolean
          description: 'True when `idempotency_key` had already placed this order
            (a retry whose response the client never saw): nothing was written now.
            With `placed: true`, `message` says it was already placed — report it
            as an order already placed, never celebrate it as a new one. With `placed:
            false` it is that order''s still-open payment: open `payment.checkout_url`
            as for a new one. False for a new order.'
          example: false
        replay_status:
          type: string
          enum:
          - placed
          - cancelled
          - declined
          description: 'Present on a replay answered `placed: true` — what became
            of the order `idempotency_key` placed. `placed`: it stands (whatever its
            progress). `cancelled`: it was cancelled since (an abandoned card payment,
            the buyer, an admin). `declined`: its approver turned it down. Nothing
            was placed now in any of them, and nothing is owed — which is all `placed:
            true` promises — so read `replay_status`, not `placed`, to tell them apart:
            for `cancelled` and `declined` show the `message` (it says so and how
            to start again), never a success screen.'
          example: placed
        order:
          "$ref": "#/components/schemas/CompanyStoreCheckoutOrderCard"
        requires_approval:
          type: boolean
          description: Points paths only. True means the order is `pending_approval`
            with the points HELD — report the hold, do not celebrate.
        payment:
          "$ref": "#/components/schemas/CompanyStoreCheckoutPayment"
        wallet:
          "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
        message:
          type: string
          description: Buyer-facing confirmation. Safe to show verbatim. On a replay
            it says the order was already placed, or that it was cancelled (or not
            approved) since and how to order it again.
          example: Order ORD-20260819-ABC123 placed.
    CompanyStoreCheckoutCompleteResponse:
      type: object
      properties:
        placed:
          type: boolean
          example: true
        order:
          "$ref": "#/components/schemas/CompanyStoreCheckoutOrderCard"
        wallet:
          "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
        message:
          type: string
          example: Payment received. Order ORD-20260819-ABC123 is confirmed.
        unread_notification_count:
          type: integer
          description: Native app badge count (the shared api/v1 envelope).
    CompanyStoreCheckoutAbandonResponse:
      type: object
      properties:
        cancelled:
          type: boolean
          example: true
        order:
          "$ref": "#/components/schemas/CompanyStoreCheckoutOrderCard"
        points_restored:
          type: boolean
        points_restored_amount:
          type: integer
        wallet:
          "$ref": "#/components/schemas/CompanyStoreCheckoutWallet"
        message:
          type: string
          example: Checkout cancelled. 700 points have been returned to your balance.
        unread_notification_count:
          type: integer
          description: Native app badge count (the shared api/v1 envelope).
    CompanyStoreCheckoutOrderCard:
      type: object
      description: The shared Company Store order row — the same shape the dashboard's
        recent orders and the orders list render, so one order never looks like two
        different things. Full detail is `GET /company-store/orders/{order_number}`.
      properties:
        id:
          type: integer
        order_number:
          type: string
          example: ORD-20260819-ABC123
        item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
        item_name:
          type: string
        quantity:
          type: integer
        status:
          type: string
          enum:
          - pending_approval
          - pending
          - processing
          - fulfilled
          - cancelled
          - refunded
        status_label:
          type: string
          description: Canonical label — `pending_approval` reads "Awaiting Approval"
            on every surface.
        points_spent:
          type: integer
        cash_amount:
          type: number
        total_label:
          type: string
        placed_at:
          type: string
          format: date-time
        url:
          type: string
    CompanyStoreCheckoutError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Machine-readable. See each response's description for the
                codes it can return.
            message:
              type: string
              description: Buyer-facing. For `checkout_failed` this is the service's
                own prose (with the real numbers in it) — show it.
            details:
              type: object
              nullable: true
              description: 'Present on the 409s: `order` carries the order''s current
                state for a polling client, and `cancel_url` on `not_abandonable`
                points at the endpoint to use instead.'
    CompanyStoreConfigViewer:
      type: object
      description: Who is asking. The namespace's shared viewer card, identical on
        every Company Store endpoint. This is the ONLY place role appears in this
        payload.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
        is_manager:
          type: boolean
          description: 'Does this viewer have a redemption approval queue — Store::RedemptionApprovalService.can_approve_redemptions?,
            the same predicate the approvals queue''s own door gates on. THE flag
            that gates the manager UI. True for a designated approver-group member,
            and for a line manager with direct reports when no group is configured;
            NOT a `role: manager` membership check.'
        is_store_admin:
          type: boolean
          description: A business admin-or-above or a Company Store app-admin. Offers
            the item / order management surfaces.
    CompanyStoreConfigFeatures:
      type: object
      description: The TENANT switches that decide which surfaces exist at all. The
        first six are shared verbatim with the other Company Store endpoints; `points_expiry_enabled`
        is merged on here for the same reason the Points tab merges it.
      properties:
        points_redemption_enabled:
          type: boolean
          description: Default true. Whether the store takes points at all.
        cash_purchases_enabled:
          type: boolean
          description: Default FALSE. A tenant that never opted in must not be told
            cash checkout exists.
        mixed_payments_enabled:
          type: boolean
          description: Default false. The RAW admin toggle — NOT whether a split payment
            can be taken, which additionally needs both of the two flags above. Use
            `payment_methods` for what is actually offerable.
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
          description: Whether the tenant has any active region, i.e. whether region
            scoping is in force.
        points_per_dollar:
          type: integer
          description: The conversion rate a mixed-payment slider needs. Coerced server-side
            to a positive Integer (admins have saved it as a string).
          example: 100
        points_expiry_enabled:
          type: boolean
          description: 'Whether the tenant expires points at all. Distinguishes `points.expiring_points:
            0` meaning "nothing is close" from "this tenant does not expire points".'
    CompanyStoreConfigRegion:
      type: object
      nullable: true
      description: The region this payload was scoped to. null when regions are off,
        or when they are on and this user resolves to none (the pool then narrowed
        to global items) — `features.regions_enabled` distinguishes the two.
      properties:
        id:
          type: integer
        name:
          type: string
        country_code:
          type: string
          nullable: true
    CompanyStoreConfigAvailableRegion:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        country_code:
          type: string
          nullable: true
        active:
          type: boolean
          description: True for the region this payload was scoped to.
    CompanyStoreConfigCategory:
      type: object
      description: 'One row of the category-filter bottom sheet. The first row of
        the array is always the "All Categories" row, which carries `key: null` and
        the true total.'
      properties:
        key:
          type: string
          nullable: true
          description: The value `?category=` takes VERBATIM — the StoreItem column
            vocabulary, NOT the merchandising label. null on the "All Categories"
            row.
          enum:
          - swag
          - gift_card
          - experience
          - charitable
          -
        label:
          type: string
          description: The storefront's merchandising label, which deliberately differs
            from `key` — `charitable` is sold as "Donate", and the other three are
            pluralised.
          example: Gift Cards
        icon:
          type: string
          description: A stable FontAwesome-style icon key, never an image URL. `tag`
            on the All row; `tshirt` / `gift` / `star` / `hand-holding-heart` for
            the four categories, with `box` as the fallback. The same keys the web
            storefront renders.
          example: gift
        color:
          type: string
          description: Bootstrap contextual colour for the chip, shared with the web.
          example: success
        count:
          type: integer
          description: How many available items this row would land on, under the
            same region / audience / category-enablement filters the catalog listing
            applies. Computed in ONE grouped query for the whole sheet. A count of
            0 means the category is ON and empty — a category the admin switched OFF
            is absent from the array entirely.
        has_items:
          type: boolean
          description: "`count > 0`. For a client that wants the web dropdown's behaviour
            of hiding empty categories."
        active:
          type: boolean
          description: Which row is selected. Always the "All Categories" row on this
            endpoint, since a bootstrap has no applied filter.
    CompanyStoreConfigPaymentMethod:
      type: object
      description: One payment method the TENANT accepts. Only enabled methods appear.
        This is the ceiling — an individual item may offer fewer (see the item detail's
        `payment_options`) but never one outside this array.
      properties:
        key:
          type: string
          enum:
          - points
          - cash
          - mixed
        label:
          type: string
          example: Points
        description:
          type: string
          example: Redeem with your points balance
    CompanyStoreConfigPoints:
      type: object
      description: The caller's OWN wallet, for every role. Plain-noun keys; the screen
        endpoints serve the same figures under the namespace's `balance_card` spelling
        (`points_balance`, `pending_points`, …).
      properties:
        balance:
          type: integer
          description: Spendable points.
        pending:
          type: integer
          description: Earned but not yet released.
        lifetime_earned:
          type: integer
        lifetime_spent:
          type: integer
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
        points_per_dollar:
          type: integer
          description: Mirrors `features.points_per_dollar`, carried here so the wallet
            block is self-contained.
        expiring_points:
          type: integer
          description: What NEWLY expires within `expiring_within_days` — never the
            whole expirable pool. Always 0 when `features.points_expiry_enabled` is
            false.
        expiring_within_days:
          type: integer
          description: The expiry warning horizon (ExpireStorePointsJob::WARN_DAYS).
          example: 14
    CompanyStoreConfigRedemption:
      type: object
      description: The rules a redemption is subject to before it becomes an order,
        mirroring Store::CheckoutService's own readings so a client can disclose a
        hold or a cap BEFORE the user commits. Every figure is null when off, never
        0.
      properties:
        approval_threshold_points:
          type: integer
          nullable: true
          description: The points figure AT OR ABOVE which a redemption is HELD for
            approval instead of spending immediately (checkout compares `>=`). The
            LOWEST enabled of the admin and manager tiers, since that is the one that
            actually holds; a tier set to 0 is off. null = no tier enabled.
        monthly_points_cap:
          type: integer
          nullable: true
          description: Per-user calendar-month ceiling on redeemed points. null =
            unlimited.
        monthly_points_remaining:
          type: integer
          nullable: true
          description: What is left of that ceiling for THIS caller this month, counting
            the same set checkout counts (points and mixed orders, cancelled and refunded
            excluded). Floored at 0. null when there is no cap — and the query behind
            it is then skipped entirely.
        velocity_window_hours:
          type: integer
          description: The short-window fraud brake's window. Reports the checkout
            service's own 24h fallback when unset.
          example: 24
        velocity_max_orders:
          type: integer
          nullable: true
          description: Redemptions allowed inside the window. A breach does NOT block
            — it forces the approval hold. null = off.
        velocity_max_points:
          type: integer
          nullable: true
          description: Points allowed inside the window. Same non-blocking hold semantics.
            null = off.
    CompanyStoreOrdersViewer:
      type: object
      description: Who is asking. Shared verbatim with every other Company Store endpoint.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
        is_manager:
          type: boolean
          description: Does this viewer have a redemption approval queue — the one
            thing that separates the design's Manager persona from its Employee one.
            It does NOT widen the order list; the approval queue is its own surface.
        is_store_admin:
          type: boolean
          description: A business admin/owner or a Company Store app-admin.
    CompanyStoreOrdersFeatures:
      type: object
      description: The TENANT switches, shared verbatim with the other Company Store
        endpoints. Present on the orders payload because the reorder affordance depends
        on them — an item is only reorderable through a payment path the tenant actually
        enabled.
      properties:
        points_redemption_enabled:
          type: boolean
        cash_purchases_enabled:
          type: boolean
        mixed_payments_enabled:
          type: boolean
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
        points_per_dollar:
          type: integer
    CompanyStoreOrderStatusCounts:
      type: object
      description: Order counts by status from ONE grouped query. Every key is always
        present (0 when empty), so a filter bar renders a stable set of pills instead
        of reading an absent key as zero. Honours `search`, not `status`.
      required:
      - all
      - pending_approval
      - pending
      - processing
      - fulfilled
      - cancelled
      - refunded
      properties:
        all:
          type: integer
          description: Every order in the scope, in every status — the "All" pill.
          example: 14
        pending_approval:
          type: integer
          example: 1
        pending:
          type: integer
          example: 2
        processing:
          type: integer
          example: 3
        fulfilled:
          type: integer
          example: 6
        cancelled:
          type: integer
          example: 2
        refunded:
          type: integer
          example: 0
    CompanyStoreOrderStatusFilter:
      type: object
      description: One pill in the filter row.
      properties:
        value:
          type: string
          nullable: true
          description: The `status` query value this pill applies. **null** is the
            All pill.
        label:
          type: string
          description: Display label. `pending_approval` reads "Awaiting Approval";
            `cancelled` reads "Cancelled / not approved", since the bucket also holds
            redemptions an approver turned down.
          example: Awaiting Approval
        count:
          type: integer
          description: The same figure `counts` reports for this status.
        selected:
          type: boolean
          description: True for the pill matching the applied filter (the All pill
            when none is applied).
        visible:
          type: boolean
          description: Whether the web chip row would show this pill. False only for
            `pending_approval` / `refunded` while they have no rows and are not selected.
    CompanyStoreOrderListRow:
      type: object
      description: One row in the order list — the shared `CompanyStoreOrderRow` card
        plus the three fields the orders screen adds, and `employee` in the `all`
        scope.
      properties:
        id:
          type: integer
        order_number:
          type: string
          example: ORD-20260819-ABC123
        item:
          type: object
          nullable: true
          description: null when the item row has since been deleted — the row still
            has to render, so use `item_name` for the label.
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
              description: Absolute URL.
        item_name:
          type: string
          description: Always populated ("Item" for a deleted item row).
        quantity:
          type: integer
        status:
          type: string
          enum:
          - pending_approval
          - pending
          - processing
          - fulfilled
          - cancelled
          - refunded
        status_label:
          type: string
          description: Canonical display label — "Awaiting Approval" for `pending_approval`,
            "Not approved" for a `cancelled` order an approver turned down.
        points_spent:
          type: integer
        cash_amount:
          type: number
          format: float
        total_label:
          type: string
          description: Pre-formatted money summary, e.g. "1200 pts" or "1200 pts +
            $5.00".
        payment_type:
          type: string
          enum:
          - points
          - cash
          - mixed
        variant_display:
          type: string
          nullable: true
          description: 'Selected variants as one line, e.g. "Size: L, Color: Black".
            null when the item has none.'
        has_fulfillment_error:
          type: boolean
          description: True only while a FAILED order is still `pending`. A resolved
            order reports false, so never treat this as sticky.
        placed_at:
          type: string
          format: date-time
        url:
          type: string
          description: Absolute URL of the web order page.
        employee:
          "$ref": "#/components/schemas/CompanyStorePerson"
    CompanyStorePerson:
      type: object
      nullable: true
      description: Whose order this is. Present on a list row ONLY in the `all` scope
        (in `mine` every row is the caller's, so naming them per row is noise), and
        always present on the detail payload.
      properties:
        id:
          type: integer
        name:
          type: string
        title:
          type: string
          nullable: true
        image:
          type: string
          nullable: true
    CompanyStoreOrdersPageMeta:
      type: object
      description: The page envelope every paginated Company Store endpoint answers
        with — one shape, produced by one shared helper (`Api::V1::CompanyStore::BaseController#pagination_meta`).
        Structurally identical to `CompanyStorePageMeta` in the sibling catalog spec
        file; the two should collapse into one schema once both surfaces have landed.
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total_count:
          type: integer
          description: Rows in the FILTERED pool (status + search), not on this page.
        total_pages:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreOrderTimelineStep:
      type: object
      description: One step of the derived lifecycle timeline.
      properties:
        key:
          type: string
          enum:
          - placed
          - awaiting_approval
          - processing
          - delivered
          - cancelled
          - refunded
        label:
          type: string
          description: Display label. The delivery step reads "Delivered" for a digital
            reward and "On its way" for anything shipped that is still to come, so
            a gift card never promises a truck; once a shipped order is fulfilled
            it reads "Fulfilled", the order's own status (nothing records that it
            arrived).
          example: On its way
        detail:
          type: string
          nullable: true
          description: The sentence beside the label — the reassurance copy, or the
            delivery estimate ("Est. 5 business days after it ships", or "Delivery
            estimate not available" when the item carries no lead time).
        state:
          type: string
          enum:
          - done
          - current
          - upcoming
          - cancelled
          - refunded
        icon:
          type: string
          description: Font Awesome class the web page paints; a native client may
            map its own.
          example: fas fa-truck
        at:
          type: string
          format: date-time
          nullable: true
          description: When the step happened. null for a step that hasn't happened
            yet, and for a legacy terminal row that never got a timestamp.
    CompanyStoreOrderDetail:
      type: object
      description: One order in full. Carries every `CompanyStoreOrderListRow` field
        plus the blocks below.
      properties:
        id:
          type: integer
        order_number:
          type: string
        item:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            image_url:
              type: string
              nullable: true
        item_name:
          type: string
        quantity:
          type: integer
        status:
          type: string
        status_label:
          type: string
        points_spent:
          type: integer
        cash_amount:
          type: number
          format: float
        total_label:
          type: string
        payment_type:
          type: string
          enum:
          - points
          - cash
          - mixed
        placed_by:
          type: object
          nullable: true
          description: Set when a store admin placed the order for the employee ("Redeem
            for employee"), spending the employee's points. null for an order the
            employee placed themselves.
          properties:
            name:
              type: string
              description: The admin who placed it.
            reason:
              type: string
              nullable: true
              description: The reason the admin gave.
        placed_at:
          type: string
          format: date-time
        url:
          type: string
        viewer:
          "$ref": "#/components/schemas/CompanyStoreOrdersViewer"
        features:
          "$ref": "#/components/schemas/CompanyStoreOrdersFeatures"
        is_mine:
          type: boolean
          description: False when a store admin is viewing someone else's order. Every
            entry in `actions` is then false — viewing is not acting.
        employee:
          "$ref": "#/components/schemas/CompanyStorePerson"
        variant_display:
          type: string
          nullable: true
        selected_variants:
          type: object
          additionalProperties:
            type: string
          description: 'The chosen variants as a map, e.g. {"size": "L"}. Empty when
            the item has none.'
        personalization:
          type: object
          nullable: true
          description: Engraving details captured at checkout for personalizable items
            (recipient_name, and optionally years_of_service / engraving_line). null
            for everything else.
          additionalProperties:
            type: string
        line_items:
          type: array
          description: Every line the order contains. A multi-item cart order has
            real rows; a single-item redemption gets ONE synthesised line, so a client
            renders the same list either way.
          items:
            type: object
            properties:
              item_id:
                type: integer
                nullable: true
              name:
                type: string
              quantity:
                type: integer
              points_price:
                type: integer
              cash_price:
                type: number
                format: float
              selected_variants:
                type: object
                additionalProperties:
                  type: string
        dates:
          type: object
          properties:
            placed_at:
              type: string
              format: date-time
            fulfilled_at:
              type: string
              format: date-time
              nullable: true
            cancelled_at:
              type: string
              format: date-time
              nullable: true
            refunded_at:
              type: string
              format: date-time
              nullable: true
        timeline:
          type: array
          description: Ordered lifecycle steps — see the endpoint description.
          items:
            "$ref": "#/components/schemas/CompanyStoreOrderTimelineStep"
        fulfillment:
          type: object
          description: The reward payload — what the employee came to this screen
            for once the order lands. Fields are null until fulfilment provides them.
          properties:
            tracking_number:
              type: string
              nullable: true
            carrier:
              type: string
              nullable: true
            tracking_url:
              type: string
              nullable: true
              description: The provider's own deep link when it supplied one, otherwise
                built from a known carrier + number. null means render the bare number.
            gift_card_code:
              type: string
              nullable: true
            reward_link:
              type: string
              nullable: true
            redemption_instructions:
              type: string
              nullable: true
              description: The provider's "how to redeem" text for the card, as plain
                text (links kept as "label (url)"). null when the provider sent none.
            delivery_email:
              type: string
              nullable: true
              description: Where the redemption link was sent.
            donation_receipt:
              type: string
              nullable: true
              description: Receipt number for a charitable redemption, when the admin
                recorded one.
            has_gift_card_link:
              type: boolean
              description: True when either a code or a redemption link is available.
        shipping_address:
          type: object
          nullable: true
          description: null for anything that doesn't ship, and for a shipped order
            with no address recorded. `lines` is the same formatted block the web
            page prints, so both surfaces render one address format.
          properties:
            lines:
              type: array
              items:
                type: string
        approval:
          type: object
          nullable: true
          description: Present ONLY while the redemption is actually held — that is
            the only time "who has to approve this" is a live question.
          properties:
            required_approver:
              type: string
              enum:
              - manager
              - admin
              description: The tier stamped at checkout. A legacy hold with none reads
                `admin`.
            manager_approval:
              type: boolean
            points_held:
              type: integer
              description: The points held while the decision is pending.
        not_approved:
          type: object
          nullable: true
          description: 'Present ONLY when an approver turned the redemption down (status
            `cancelled`, `status_label` "Not approved"): who decided, when and why
            — the same words the web and phone order pages show. null otherwise.'
          properties:
            decided_by:
              type: string
              nullable: true
              description: In the viewer's words — "your manager", "an admin", or
                the approver group's name; "their manager" to an admin reading someone
                else's order.
            decided_at:
              type: string
              format: date-time
              nullable: true
            reason:
              type: string
              nullable: true
              description: The approver's own words.
        return_request:
          type: object
          nullable: true
          description: The latest return / exchange request, so a client shows "Return
            requested" instead of re-offering the button. null when there is none.
          properties:
            id:
              type: integer
            status:
              type: string
              enum:
              - requested
              - approved
              - rejected
              - withdrawn
              - replacement_sent
              description: "`withdrawn` — the buyer took it back while it was open;
                the order is free for a new return or report. `replacement_sent` —
                an approved exchange whose replacement the store has sent (see `replacement`)."
            return_type:
              type: string
              enum:
              - refund
              - exchange
            reason:
              type: string
            requested_at:
              type: string
              format: date-time
            resolution_notes:
              type: string
              nullable: true
              description: The store admin's notes on the decision (why a return was
                not approved).
            processed_at:
              type: string
              format: date-time
              nullable: true
            requested_variants:
              type: object
              nullable: true
              additionalProperties:
                type: string
              description: 'An exchange''s wanted variant as filed (e.g. {"size":
                "XL"}); null for a refund or an exchange that named none.'
            requested_variant_label:
              type: string
              nullable: true
              description: 'The same in words, named by the item''s own variant labels
                — "Size: XL".'
            replacement:
              type: object
              nullable: true
              description: An exchange's replacement once the store marked it sent;
                null until then (and for a refund).
              properties:
                sent_at:
                  type: string
                  format: date-time
                  nullable: true
                carrier:
                  type: string
                  nullable: true
                  description: One of UPS, FedEx, USPS, DHL or Other; null when none
                    was given.
                tracking_number:
                  type: string
                  nullable: true
                tracking_url:
                  type: string
                  nullable: true
                  description: The carrier's tracking page, when the carrier is one
                    the store links.
            withdraw_url:
              type: string
              nullable: true
              description: 'Present only while the caller''s OWN request is open:
                POST it to take it back (POST /company-store/orders/{order_number}/withdraw-return).'
        actions:
          type: object
          description: What this viewer may actually do next, resolved against the
            same predicates the write paths enforce — including this API's own cancel
            endpoint, so a control rendered from here is one whose POST is accepted.
            `can_cancel` is offered to both people the web offers it to (the buyer,
            and a store admin on anyone's order); the other three are the BUYER's
            own acts and are false for an admin however wide their read access.
          properties:
            can_cancel:
              type: boolean
              description: True when POST /company-store/orders/{order_number}/cancel
                would be accepted for this viewer — the very same `StoreOrder#cancel_actor_for`
                reading that endpoint's gate uses.
            cancel_as:
              type: string
              nullable: true
              enum:
              - owner
              - admin
              description: Which cancel power this viewer holds. `owner` is the buyer's
                self-service cancel, `admin` a store admin acting on an order — worth
                distinguishing, because an admin cancelling somebody else's order
                deserves a different confirmation. null when they hold none.
            cancel_blocked_reason:
              type: string
              nullable: true
              description: Why cancel is unavailable while the status still looks
                cancellable — already dispatched to the reward provider, not the caller's
                order, or a cash refund needing a business administrator. null when
                the order can be cancelled, and when there is no cancel affordance
                at all (a terminal order has the return path instead).
            can_return:
              type: boolean
              description: A fulfilled PHYSICAL order with no open/approved return.
            can_report_problem:
              type: boolean
              description: A fulfilled DIGITAL order — not physically returnable,
                but it can be reported as a dud.
            return_url:
              type: string
              nullable: true
              description: Where `can_return` / `can_report_problem` is sent — POST
                /company-store/orders/{order_number}/request-return. Null when neither
                is offered.
            return_reasons:
              type: array
              description: 'The reasons that POST takes for this order, as the web
                form labels them: a return''s physical reasons, or a problem report''s
                digital ones. Empty when neither is offered.'
              items:
                type: object
                properties:
                  value:
                    type: string
                  label:
                    type: string
            can_reorder:
              type: boolean
              description: True only when the item still exists, is available, its
                category toggle is on, and at least one enabled payment path applies
                — the same four-part test the web "Order Again" button uses, so the
                deep link is never doomed.
            reorder_url:
              type: string
              nullable: true
              description: Absolute checkout URL carrying the original quantity and
                variants.
        admin:
          type: object
          description: Store admins only — absent entirely for everyone else. The
            web shows these on the admin order page; an employee gets reassurance
            copy, not a connector error string.
          properties:
            provider_order_id:
              type: string
              nullable: true
            provider_status:
              type: string
              nullable: true
            funding_source:
              type: string
              enum:
              - tenant_byo
              - platform_managed
            fulfillment_error:
              type: string
              nullable: true
              description: The connector's own failure message.
            can_retry_fulfillment:
              type: boolean
            admin_url:
              type: string
              description: Absolute URL of the web admin order page, where the decisions
                live.
    CompanyStoreOrderCancelResponse:
      type: object
      description: The receipt for the cancellation, plus the whole order re-rendered
        so the client needs no follow-up GET.
      properties:
        order:
          allOf:
          - "$ref": "#/components/schemas/CompanyStoreOrderDetail"
          description: The order as it now stands — status `cancelled`, a `cancelled`
            terminal step on the timeline, and `actions.can_cancel` withdrawn.
        cancellation:
          type: object
          description: What was just done, and to what.
          properties:
            order_number:
              type: string
              example: ORD-20260819-ABC123
            status:
              type: string
              example: cancelled
            status_label:
              type: string
              example: Cancelled
            cancelled_at:
              type: string
              format: date-time
            cancelled_by:
              allOf:
              - "$ref": "#/components/schemas/CompanyStorePerson"
              description: Whoever took the action — the buyer, or the admin who cancelled
                for them.
            actor:
              type: string
              enum:
              - owner
              - admin
              description: Which power was exercised. `owner` is the buyer's self-service
                cancel; `admin` a store admin acting on an order.
            reason:
              type: string
              nullable: true
              description: The `reason` sent with the request, as recorded. null when
                none was sent.
            points_refunded:
              type: integer
              description: Points returned to the employee's wallet — the PRE-write
                reading, so it still reports what the order held.
              example: 900
            card_refund_pending:
              type: boolean
              description: True when a captured card charge is being reversed. The
                Stripe refund runs asynchronously, so this means "a refund is on its
                way", never "the money is back".
            inventory_restored:
              type: integer
              description: Units actually returned to the item's stock — the order
                quantity, or 0 for an item with unlimited inventory, which tracks
                none.
              example: 1
        message:
          type: string
          description: 'Ready-to-show confirmation copy, phrased for whoever asked:
            the buyer is told about their own balance, an admin about the employee''s.
            Built from the same three facts the web self-cancel flash is built from.'
          example: Order ORD-20260819-ABC123 was cancelled. 900 points were returned
            to your balance.
        unread_notification_count:
          type: integer
          description: Native app badge count (the shared api/v1 envelope).
    CompanyStoreOrderCancelError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - store_disabled
              - forbidden
              - cash_reversal_forbidden
              - not_cancellable
              - cancel_failed
              - not_found
            message:
              type: string
              description: Ready-to-show copy, and the SAME sentence the web surfaces
                show for the same refusal — an admin is never told to "contact your
                store admin", because they are one.
              example: This order has already been sent to the reward provider, so
                it can't be cancelled here.
    CompanyStorePointsViewer:
      type: object
      description: Who is asking. Shared verbatim with every other Company Store endpoint.
        Reported for consistency and so a client can offer the admin surfaces — neither
        flag changes anything in this payload, which is always the caller's own wallet.
      properties:
        id:
          type: integer
        name:
          type: string
        image:
          type: string
          nullable: true
        is_manager:
          type: boolean
          description: Does this viewer have a redemption approval queue. It does
            NOT widen the points payload; the approval queue is its own surface.
        is_store_admin:
          type: boolean
          description: A business admin/owner or a Company Store app-admin. It does
            NOT let this endpoint read someone else's wallet — that is the admin balances
            surface.
    CompanyStorePointsFeatures:
      type: object
      description: The TENANT switches. The first six are shared verbatim with the
        other Company Store endpoints; `points_expiry_enabled` is specific to this
        screen.
      properties:
        points_redemption_enabled:
          type: boolean
        cash_purchases_enabled:
          type: boolean
        mixed_payments_enabled:
          type: boolean
        recognition_integration_enabled:
          type: boolean
        regions_enabled:
          type: boolean
        points_per_dollar:
          type: integer
        points_expiry_enabled:
          type: boolean
          description: 'Whether the tenant expires points at all (`points_expiry_months`
            > 0). This is what distinguishes `balance.expiring_points: 0` meaning
            "nothing is close" from "this tenant does not expire points" — the second
            should render no expiry banner or countdown.'
    CompanyStorePointsBalance:
      type: object
      description: The wallet hero. The first six fields are the shared balance block
        every Company Store endpoint reports; the expiry pair is this screen's (and
        the dashboard's) addition.
      required:
      - points_balance
      - pending_points
      - lifetime_points_earned
      - lifetime_points_spent
      - expiring_points
      - expiring_within_days
      properties:
        points_balance:
          type: integer
          description: Spendable points right now. The "Available Points" tile.
          example: 2450
        pending_points:
          type: integer
          description: Earned but not yet credited — a recognition has landed and
            is awaiting release. Not spendable, so never add it to `points_balance`
            when deciding affordability.
          example: 150
        lifetime_points_earned:
          type: integer
          example: 8200
        lifetime_points_spent:
          type: integer
          description: Lifetime points redeemed. Expiries are NOT counted here — breakage
            is not a redemption.
          example: 5750
        last_earned_at:
          type: string
          format: date-time
          nullable: true
        last_spent_at:
          type: string
          format: date-time
          nullable: true
        expiring_points:
          type: integer
          description: What NEWLY expires within `expiring_within_days` — the figure
            the web banner claims and the expiry warning notification sends. NOT the
            total currently-expirable pool. Always 0 when `features.points_expiry_enabled`
            is false.
          example: 300
        expiring_within_days:
          type: integer
          description: The warn horizon `expiring_points` was computed over.
          example: 14
    CompanyStorePointsActivity:
      type: object
      description: The activity roll-up over the last `period_days`, from ONE grouped
        query.
      required:
      - period_days
      - earned
      - spent
      - adjustments
      - transaction_count
      properties:
        period_days:
          type: integer
          description: The window actually computed — 90 on this screen, deliberately
            NOT the dashboard widget's 30. Render the header from this rather than
            hardcoding it.
          example: 90
        earned:
          type: integer
          description: Sum of credits in the window (positive).
          example: 1200
        spent:
          type: integer
          description: Sum of debits in the window, as a POSITIVE magnitude.
          example: 800
        adjustments:
          type: integer
          description: Net of admin adjustments in the window — SIGNED, because an
            adjustment can go either way, so this may be negative.
          example: -50
        refunded:
          type: integer
          description: Points cancelled, declined or refunded orders gave back in
            the window (positive). Neither earned nor spent.
          example: 1500
        expired:
          type: integer
          description: Points the tenant's expiry policy took in the window, as a
            POSITIVE magnitude (like `spent`). Show it whenever it is non-zero, or
            `transaction_count` counts rows no figure accounts for.
          example: 700
        transaction_count:
          type: integer
          description: Every row in the window, of any type — the sum of the rows
            behind `earned`, `spent`, `adjustments`, `refunded` and `expired` (and
            any future type).
          example: 18
    CompanyStorePointsTrend:
      type: object
      description: The 6-month earned-vs-spent trend the web page charts.
      required:
      - months
      - has_activity
      properties:
        months:
          type: array
          description: Always 6 entries, oldest first, including zero-activity months
            so a chart renders at a stable width.
          items:
            "$ref": "#/components/schemas/CompanyStorePointsTrendMonth"
        has_activity:
          type: boolean
          description: True when any month has non-zero earned or spent. What the
            web page gates the whole card on — a flat all-zero chart is worse than
            no chart.
    CompanyStorePointsTrendMonth:
      type: object
      properties:
        month:
          type: string
          format: date
          description: First day of the month, for sorting and locale-aware formatting.
          example: '2026-08-01'
        label:
          type: string
          description: Server-rendered short label, matching the web chart's axis.
          example: Aug 2026
        earned:
          type: integer
          description: Credits in the month.
          example: 400
        spent:
          type: integer
          description: Debits in the month, as a POSITIVE magnitude. Adjustments and
            expiries appear in NEITHER series — this is the earn-vs-redeem picture,
            not a net-change chart.
          example: 250
    CompanyStorePointsTypeCounts:
      type: object
      description: ALL-TIME counts by transaction type from ONE grouped query. Every
        key is always present (0 when empty), so a filter row renders a stable set
        of pills instead of reading an absent key as zero. Deliberately NOT narrowed
        by the active `type` — see the endpoint description.
      required:
      - all
      - credit
      - debit
      - adjustment
      - expiry
      - refund
      properties:
        all:
          type: integer
          description: Every row in the caller's history, of every type — the "All"
            pill. This is the WHOLE history even when the list is filtered; the filtered
            depth is `meta.total_count`.
          example: 42
        credit:
          type: integer
          example: 28
        debit:
          type: integer
          example: 11
        adjustment:
          type: integer
          example: 3
        expiry:
          type: integer
          example: 0
        refund:
          type: integer
          example: 0
    CompanyStorePointsTypeFilter:
      type: object
      description: One pill in the filter row.
      properties:
        value:
          type: string
          nullable: true
          description: The `type` query value this pill applies. **null** is the All
            pill.
          enum:
          - credit
          - debit
          - adjustment
          - expiry
          - refund
          -
        label:
          type: string
          description: 'Display label — one word per type on every surface, the web
            chip and row badge''s (PointsTransaction.type_label), in display order:
            "All", "Earned", "Redemption", "Refund", "Adjustment", "Expired".'
          example: Earned
        count:
          type: integer
          description: The same number `counts` reports for this type.
          example: 28
        selected:
          type: boolean
          description: Whether this pill is the filter currently in force.
        visible:
          type: boolean
          description: Whether the web would render this pill. False only for `adjustment`
            and `expiry` while their count is 0 and they are not selected. A client
            may ignore this and render all six.
    CompanyStorePointsTaxStatement:
      type: object
      description: The self-service tax statement link, mirroring the web header button.
      required:
      - available
      properties:
        available:
          type: boolean
          description: True only when the viewer has taxable redemptions in a year
            the statement page offers. False means do not render the link — it would
            dead-end on an empty statement.
        year:
          type: integer
          nullable: true
          description: The most recent offered year that actually HAS rows — not necessarily
            the current year. In Jan–Apr they usually differ, so link to this value
            rather than to "this year".
          example: 2025
        url:
          type: string
          nullable: true
          description: Absolute URL to the statement for `year`. null when unavailable.
    CompanyStorePointsTransaction:
      type: object
      description: One row of the points ledger.
      required:
      - id
      - transaction_type
      - type_label
      - amount
      - display_amount
      - absolute_amount
      - positive
      - balance_after
      - description
      - created_at
      properties:
        id:
          type: integer
        transaction_type:
          type: string
          enum:
          - credit
          - debit
          - adjustment
          - expiry
          - refund
        type_label:
          type: string
          description: The type's one word, matching the web badge and filter pill
            (PointsTransaction.type_label).
          example: Earned
        amount:
          type: integer
          description: SIGNED. Positive for credits, negative for debits and expiries,
            either for adjustments.
          example: -500
        display_amount:
          type: string
          description: Pre-formatted signed string, as the web prints it.
          example: "-500"
        absolute_amount:
          type: integer
          description: Magnitude, sign discarded.
          example: 500
        positive:
          type: boolean
          description: The direction of the entry. Report this rather than inferring
            from the type — an `adjustment` can go either way, which is why the web
            colours the amount on the sign.
        balance_after:
          type: integer
          description: The caller's spendable balance immediately after this entry.
          example: 1950
        description:
          type: string
          description: What happened, as the web row prints it. Points a recognition
            or a boost gave name the giver as they are now ("Recognition from Dana
            Reyes", "Boost from Sam Ortiz"; "Deleted user" once they are erased, "Anonymous"
            for an anonymous recognition). Falls back to a source-derived phrase ("Recognition
            received", "Store redemption") for rows stored without one.
          example: Redeemed Branded Hoodie
        notes:
          type: string
          nullable: true
          description: The admin's stated reason on an adjustment. The employee already
            receives it in the adjustment notification. null on every other type.
        source:
          type: object
          nullable: true
          description: A REFERENCE to the record that caused this entry, with the
            words the web ledgers print for its kind (`label`) — the record itself
            is not resolved, which would cost a polymorphic load per row. null for
            entries with no source (expiries, most adjustments). Fetch the record
            by reference if its name is needed.
          properties:
            type:
              type: string
              description: Model name.
              example: StoreOrder
            id:
              type: integer
              example: 8123
            label:
              type: string
              description: What the web ledgers print under the row for this kind
                of source, in product words (PointsTransaction#source_label) — never
                a module path.
              example: Store order
        created_at:
          type: string
          format: date-time
    CompanyStorePointsPageMeta:
      type: object
      description: The page envelope, identical in shape to every other paginated
        Company Store endpoint. `total_count` is the depth of the FILTERED list —
        for the whole history regardless of filter, read `counts.all`.
      required:
      - current_page
      - per_page
      - total_count
      - total_pages
      - has_next_page
      - has_prev_page
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          description: The clamped page size in force.
          example: 20
        total_count:
          type: integer
          description: Rows matching the active `type` filter.
          example: 11
        total_pages:
          type: integer
          example: 1
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    CompanyStoreShippingCountriesResponse:
      type: object
      properties:
        shipping_countries:
          type: object
          properties:
            countries:
              type: array
              description: The countries a physical order can be shipped to, in display
                order. Never empty. Render whatever length this holds rather than
                assuming one entry.
              items:
                "$ref": "#/components/schemas/CompanyStoreShippingCountry"
            default_country:
              type: string
              description: The country to select when the caller has expressed no
                preference — always the `value` of one of the rows above, so it can
                be assigned to the field directly.
              example: United States
            prefill:
              "$ref": "#/components/schemas/CompanyStoreShippingPrefill"
        unread_notification_count:
          type: integer
          description: Piggybacked on every response in this API.
        _meta:
          type: object
          description: Piggybacked response metadata.
    CompanyStoreShippingCountry:
      type: object
      properties:
        value:
          type: string
          description: '**Submit this** as `shipping_address[country]`. The display
            NAME, not the ISO code — matching the web form and what lands in `order.shipping_address["country"]`.'
          example: United States
        code:
          type: string
          description: ISO 3166-1 alpha-2. Display and validation only — do NOT submit
            it as the country field; the write would accept it and fulfillment would
            then fail on it.
          example: US
        label:
          type: string
          description: What to show in the dropdown.
          example: United States
        states:
          type: array
          description: The country's states/provinces. `[]` for a country we hold
            no list for, which means render a free-text field.
          items:
            "$ref": "#/components/schemas/CompanyStoreShippingState"
    CompanyStoreShippingState:
      type: object
      properties:
        value:
          type: string
          description: "**Submit this** as `shipping_address[state]`. The 2-letter
            code — the opposite shape to the country field, because the Printful fulfillment
            path reads it as a `state_code`."
          example: CA
        label:
          type: string
          description: What to show in the dropdown.
          example: California
    CompanyStoreShippingPrefill:
      type: object
      description: The caller's own profile values, resolved into the shapes the two
        fields submit. The only part of this payload that varies per user.
      properties:
        country:
          type: string
          description: Always one of the offered `value`s, falling back to `default_country`
            when the profile holds none or holds one we do not ship to. A profile
            country stored as an ISO code is matched and returned as the NAME.
          example: United States
        state:
          type: string
          nullable: true
          description: The profile state normalised to its 2-letter code, accepting
            a name, a code or a lowercase code on the way in. **Null** when the profile
            holds no state or one that resolves to no known code — leave the dropdown
            unselected rather than guessing.
          example: CA
        states_required:
          type: boolean
          description: Whether the prefilled country has a state list at all. `false`
            means an empty `states` array is expected and a free-text field is correct
            — it distinguishes "no list exists" from "the list failed to load".
          example: true
    CompanyStoreShippingCountriesError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - insufficient_permissions
              - access_denied
              - store_disabled
            message:
              type: string
    CompanyStoreWatchlistResponse:
      type: object
      properties:
        watchlist:
          "$ref": "#/components/schemas/CompanyStoreWatchlistState"
        unread_notification_count:
          type: integer
          description: The caller's unread in-app notification count, piggybacked
            onto every response in this API for native badge management.
          example: 3
    CompanyStoreWatchlistState:
      type: object
      description: The watch state AFTER the write — read back from the database,
        never assumed.
      properties:
        item_id:
          type: integer
          example: 412
        item_name:
          type: string
          description: So a client can compose its own copy without holding the item
            row.
          example: Company Logo Hoodie
        kind:
          type: string
          enum:
          - wishlist
          - restock
          description: The kind this call acted on (the default `wishlist` when none
            was sent).
        watching:
          type: boolean
          description: Is the caller now watching this item for `kind`? `true` on
            every successful ADD (including an idempotent repeat), `false` on every
            successful REMOVE. It describes what is TRUE now, not what this call did
            — see `changed`.
          example: true
        changed:
          type: boolean
          description: Did THIS call move anything? `false` on an idempotent repeat
            — an add of an item already watched, or a remove of one that wasn't. Suppress
            a duplicate toast on `false`; do not treat it as a failure.
          example: true
        wishlisted:
          type: boolean
          description: Is the item on the caller's saved items? Named exactly as the
            catalog card's own field, so a cached card is patched field-for-field.
            Resolved in the SAME query as `restock_watch`.
        restock_watch:
          type: boolean
          description: Does the caller hold a back-in-stock alert for this item? Named
            as `GET /company-store/catalog/{id}` reports it. Remember it is one-shot
            — it disappears once the notification is sent.
        wishlist_total:
          type: integer
          description: 'How many saved items the caller now has — `Store::DashboardStats#wishlist_total`,
            the same figure `GET /company-store/dashboard` reports as `saved_items.total`,
            so a badge updates from the response that changed it. It is the SAVED-ITEMS
            scope: watches whose item has since been discontinued, unpublished, or
            restricted to a group this caller isn''t in are not counted, exactly as
            the dashboard grid omits them. Sent on both verbs and both kinds; a `restock`
            write leaves it unchanged.'
          example: 7
        message:
          type: string
          description: 'Ready-to-display confirmation, byte-identical to the web toggle''s
            (`StoreItemWatch.state_message`) — a heart flipping 16px is not enough
            confirmation that anything was saved. One of four strings: "Added X to
            your wishlist." / "Removed X from your wishlist." / "We''ll notify you
            when X is back in stock." / "Back-in-stock alert for X turned off."'
          example: Added Company Logo Hoodie to your wishlist.
    CompanyStoreWatchlistError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - invalid_item_id
              - access_denied
              - store_disabled
              - region_unavailable
              - region_restricted
              - not_found
              - invalid_kind
              - restock_not_applicable
            message:
              type: string
              description: Human-readable, safe to display.
            details:
              type: object
              nullable: true
              description: 'Present on the three refusals that can name what was wrong:
                `{ "item_id": "<what was sent>" }` for `invalid_item_id`, `{ "kind":
                "<what was sent>" }` for `invalid_kind`, and `{ "status": "<the item''s
                status>" }` for `restock_not_applicable`.'
    OperatingCountry:
      type: object
      properties:
        code:
          type: string
          example: DE
        name:
          type: string
          example: Germany
        currency_code:
          type: string
          nullable: true
          example: EUR
        currency_symbol:
          type: string
          nullable: true
          example: "€"
        calling_code:
          type: string
          nullable: true
          example: "+49"
        flag_emoji:
          type: string
          nullable: true
        works_council:
          type: boolean
          description: Whether this business marked the country as having a works
            council (Knowledge Layer X3). Read and confirmation reports then show
            counts, not names, for people in it.
    DemandSignalsSignal:
      type: object
      description: A demand signal (every column of the row).
      required:
      - id
      - business_id
      - location_id
      - signal_type
      - signal_date
      - signal_time
      - impact_factor
      - name
      - description
      - metadata
      - active
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        business_id:
          type: integer
        location_id:
          type: integer
        signal_type:
          type: string
          enum:
          - event
          - weather
          - holiday
          - promotion
          - sale
          - seasonal
          - external
        signal_date:
          type: string
          format: date
          description: The day the signal applies to.
        signal_time:
          type: string
          format: date-time
          nullable: true
          description: Optional moment within the day.
        impact_factor:
          type: string
          description: 'Multiplier on baseline demand, as a decimal string: "1.0"
            no effect, "1.35" a 35% lift, "0.8" a 20% drop.'
          example: '1.35'
        name:
          type: string
          nullable: true
          description: Required unless `signal_type` is "weather".
        description:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
          description: Free-form JSON.
        active:
          type: boolean
          nullable: true
          description: Only `true` feeds forecasts; null (stored when an empty value
            was sent) is off.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DemandSignalsSignalWithLocation:
      allOf:
      - "$ref": "#/components/schemas/DemandSignalsSignal"
      - type: object
        required:
        - location
        properties:
          location:
            "$ref": "#/components/schemas/DemandSignalsLocation"
    DemandSignalsSignalInput:
      type: object
      description: The fields a create or update may set. Any other key is ignored.
      properties:
        location_id:
          type: integer
          description: One of the business's locations (404 "Location not found" otherwise).
        signal_type:
          type: string
          enum:
          - event
          - weather
          - holiday
          - promotion
          - sale
          - seasonal
          - external
        signal_date:
          type: string
          format: date
        signal_time:
          type: string
          format: date-time
        name:
          type: string
        description:
          type: string
        impact_factor:
          type: number
          description: Greater than 0. Defaults to 1.0 on create.
        value:
          type: number
          description: Older name for `impact_factor`, used only when `impact_factor`
            is blank.
        active:
          type: boolean
          nullable: true
          description: Defaults to true. On update, anything that does not read as
            true switches the signal off (and asks `destructive:scheduling` when it
            was on).
        metadata:
          type: object
          description: Free-form JSON; replaces the stored value.
    DemandSignalsLocation:
      type: object
      description: The signal's location — every column of the location row; the common
        ones are listed. Decimal columns (budgets, costs, latitude, longitude, weekly_contracted_hours)
        arrive as decimal strings.
      additionalProperties: true
      properties:
        id:
          type: integer
        business_id:
          type: integer
        name:
          type: string
        address:
          type: string
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        country_code:
          type: string
          nullable: true
        timezone:
          type: string
          nullable: true
        active:
          type: boolean
          nullable: true
        parent_id:
          type: integer
          nullable: true
        external_id:
          type: string
          nullable: true
        latitude:
          type: string
          nullable: true
        longitude:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    DemandSignalsListMeta:
      type: object
      required:
      - current_page
      - total_pages
      - total_count
      properties:
        current_page:
          type: integer
        total_pages:
          type: integer
        total_count:
          type: integer
    DemandSignalsValidationMessages:
      type: object
      description: A refused save, as sentences a person can be shown.
      required:
      - errors
      properties:
        errors:
          type: array
          items:
            type: string
          example:
          - Impact factor must be greater than 0
    DemandSignalsBulkItem:
      type: object
      description: One signal to create, checked by the job.
      required:
      - location_id
      - signal_type
      - signal_date
      properties:
        location_id:
          type: integer
          description: One of the business's locations; otherwise the item fails ("Location
            N not found in this business").
        signal_type:
          type: string
          enum:
          - event
          - weather
          - holiday
          - promotion
          - sale
          - seasonal
          - external
        signal_date:
          type: string
          format: date
        signal_time:
          type: string
          format: date-time
        name:
          type: string
          description: Required unless `signal_type` is "weather". Part of the match
            with an existing signal.
        description:
          type: string
        impact_factor:
          type: number
          description: Greater than 0; 1.0 when omitted.
        metadata:
          type: object
        active:
          type: boolean
          nullable: true
    DemandSignalsBulkOptions:
      type: object
      properties:
        conflict_resolution:
          type: string
          enum:
          - skip
          - overwrite
          - fail
          default: skip
          description: What an item does when a signal with the same location, date,
            type and name already exists.
        validation_mode:
          type: string
          enum:
          - strict
          - lenient
          default: strict
          description: strict stops the operation at the first failed item; lenient
            records it and continues.
    DemandSignalsBulkAccepted:
      type: object
      required:
      - bulk_operation
      - job_id
      - status_url
      properties:
        bulk_operation:
          "$ref": "#/components/schemas/DemandSignalsBulkOperation"
        job_id:
          type: string
          format: uuid
        status_url:
          type: string
          description: GET it for progress (GET /bulk_operations/status/{job_id}).
          example: "/api/v1/bulk_operations/status/0b6f1c1e-3c55-4f0b-9d0e-6a3c1f2f9a10"
    DemandSignalsBulkOperation:
      type: object
      description: The bulk operation row (every column).
      properties:
        id:
          type: integer
        business_id:
          type: integer
        user_id:
          type: integer
          nullable: true
          description: Null — this door does not record who started it.
        operation_type:
          type: string
          example: create
        data_type:
          type: string
          example: demand_signals
        status:
          type: string
          enum:
          - pending
          - processing
          - completed
          - failed
          - cancelled
        metadata:
          type: object
          description: The submitted `items` and `options`, as the job will read them.
          properties:
            items:
              type: array
              items:
                type: object
            options:
              type: object
        total_items:
          type: integer
        processed_items:
          type: integer
        successful_items:
          type: integer
        failed_items:
          type: integer
        error_messages:
          type: string
          nullable: true
          description: 'Newline-separated item errors, each "Item <index>: <reason>"
            (index from 0), at most 50 recorded.'
        job_id:
          type: string
          format: uuid
        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
        updated_at:
          type: string
          format: date-time
    DigitalSignageScreen:
      type: object
      description: One signage screen (display), as `GET /digital-signage/screens`
        lists it.
      required:
      - id
      - name
      - status
      - online
      - active
      - last_seen_at
      - location_id
      - location_name
      - is_sample_data
      - created_at
      - updated_at
      properties:
        id:
          type: integer
          example: 697
        name:
          type: string
          example: Break Room TV
        status:
          type: string
          enum:
          - online
          - offline
          - inactive
          - never_connected
          description: "`inactive` when switched off; otherwise `never_connected`
            (no heartbeat yet), `online` (heartbeat within 5 minutes) or `offline`."
        online:
          type: boolean
          description: Heartbeat within the last 5 minutes, regardless of `active`.
        active:
          type: boolean
          description: False when an admin switched the screen off.
        last_seen_at:
          type: string
          format: date-time
          nullable: true
          description: Last heartbeat (ISO 8601); null when the screen has never connected.
        awaiting_new_display_url:
          type: boolean
          description: True when the screen's display URL was replaced and no device
            has checked in on the new one yet — the device still has the old URL,
            which no longer plays. `online` can read true for a few minutes after
            the replacement.
        location_id:
          type: integer
          nullable: true
          description: The location the screen is bound to; null when unbound.
        location_name:
          type: string
          nullable: true
          description: Name of that location; null when unbound.
        is_sample_data:
          type: boolean
          description: True for a demo screen the "Load sample data" action registered
            — show it as sample data, never as real hardware (its `status` is not
            an outage). It is listed, filtered and counted like any other screen.
            A sample screen plays the business's real slides as well as the sample
            ones.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        display_url:
          type: string
          format: uri
          description: Absolute kiosk URL (`<host>/signage/display/<token>`). It embeds
            the screen's access token, the only credential the kiosk feed asks for
            — treat it as a secret. Emitted only to callers who may manage screens,
            which is every caller this list admits.
          example: https://acme.workforce.mangoapps.com/signage/display/AbCdEf123456
    DigitalSignageMediaAsset:
      type: object
      description: One media-library asset (slide), as `GET /digital-signage/media`
        lists it.
      required:
      - id
      - title
      - media_kind
      - status
      - status_label
      - caption
      - media_url
      - content_type
      - location_id
      - location_name
      - active_from
      - active_until
      - within_schedule
      - position
      - is_sample_data
      - created_at
      - updated_at
      properties:
        id:
          type: integer
          example: 42
        title:
          type: string
          example: Holiday hours
        media_kind:
          type: string
          enum:
          - image
          - video
        status:
          type: string
          enum:
          - draft
          - pending
          - approved
          - rejected
        status_label:
          type: string
          description: Human label for `status`.
          enum:
          - Draft
          - Pending review
          - Approved
          - Rejected
        caption:
          type: string
          nullable: true
        media_url:
          type: string
          format: uri
          nullable: true
          description: Absolute URL of the file (an Active Storage redirect); null
            when no file is attached.
        content_type:
          type: string
          nullable: true
          description: MIME type of the attached file; null when no file is attached.
          example: image/png
        location_id:
          type: integer
          nullable: true
          description: The location the asset is shown at; null means all locations.
        location_name:
          type: string
          nullable: true
          description: Name of that location; null for an all-locations asset.
        active_from:
          type: string
          format: date-time
          nullable: true
          description: Start of the schedule window; null means no start limit.
        active_until:
          type: string
          format: date-time
          nullable: true
          description: End of the schedule window; null means no end limit.
        within_schedule:
          type: boolean
          description: True when now falls inside the `active_from` / `active_until`
            window. Independent of approval.
        position:
          type: integer
          description: Sort position in the rotation (the list's primary order).
        is_sample_data:
          type: boolean
          description: True for a demo slide the "Load sample data" action wrote —
            show it as sample data, not as content the business uploaded. It is listed
            and filtered like any other asset (`publishable=true` does not leave it
            out for being sample), but it plays only on screens whose `is_sample_data`
            is true.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Drive:
      type: object
      properties:
        id:
          type: integer
          minimum: 1
        name:
          type: string
        display_name:
          type: string
        kind:
          type: string
        role:
          type: string
          nullable: true
    DriveItem:
      type: object
      properties:
        id:
          type: integer
          minimum: 1
        drive_id:
          type: integer
          minimum: 1
        parent_id:
          type: integer
          nullable: true
        name:
          type: string
        kind:
          type: string
          enum:
          - file
          - folder
        byte_size:
          type: integer
          nullable: true
        content_type:
          type: string
          nullable: true
        current_version_id:
          type: integer
          nullable: true
        version_number:
          type: integer
          nullable: true
        role:
          type: string
          nullable: true
        owner_name:
          type: string
          nullable: true
        updated_by_name:
          type: string
          nullable: true
        updated_at:
          type: string
          format: date-time
        scan_status:
          type: string
        can_preview:
          type: boolean
        can_download:
          type: boolean
        deleted_at:
          type: string
          nullable: true
        restorable:
          type: boolean
        url:
          type: string
          format: uri
          description: Canonical authenticated web route, not a file URL.
    DrivePagination:
      type: object
      properties:
        page:
          type: integer
          minimum: 1
        per_page:
          type: integer
          minimum: 1
        total_pages:
          type: integer
        total_count:
          type: integer
    DriveConfig:
      type: object
      properties:
        enabled:
          type: boolean
        settings:
          type: object
          properties:
            max_upload_mb:
              type: integer
              minimum: 1
            version_retention_count:
              type: integer
            external_links_enabled:
              type: boolean
            external_link_require_password:
              type: boolean
            external_link_max_days:
              type: integer
              minimum: 1
        capabilities:
          type: object
          properties:
            write:
              type: boolean
            destructive:
              type: boolean
            chat:
              type: boolean
            edit:
              type: boolean
    DrivePreview:
      type: object
      properties:
        kind:
          type: string
          enum:
          - text
          - image
          - pdf
          - audio
          - video
          - web
        text:
          type: string
        truncated:
          type: boolean
          description: Text exceeds the 262144-byte preview limit.
        url:
          type: string
          format: uri
        content_type:
          type: string
    DriveVersion:
      type: object
      properties:
        id:
          type: integer
          minimum: 1
        version_number:
          type: integer
          minimum: 1
        byte_size:
          type: integer
          nullable: true
        content_type:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        created_by_name:
          type: string
          nullable: true
        change_summary:
          type: string
          nullable: true
        scan_status:
          type: string
        can_restore:
          type: boolean
        asset_url:
          type: string
          nullable: true
        thumbnail_url:
          type: string
          nullable: true
    EmployeesCard:
      type: object
      required:
      - id
      - name
      - position
      - manager
      - department
      - hire_date
      - status
      properties:
        id:
          type: integer
        name:
          type: string
        position:
          type: string
          nullable: true
          description: The person's role type, else their effective role, else "Employee";
            null when the People directory hides Job Function from the caller.
          example: Employee
        manager:
          type: string
          nullable: true
          description: The manager's name, only when that manager is an active member
            of this business; null otherwise, when the person has no manager, or when
            the People directory hides the manager from the caller.
        department:
          type: object
          nullable: true
          description: The person's organizational department in this business, as
            its id and name; null when they have none here, or when the People directory
            hides the department from the caller.
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
              example: Bakery
        hire_date:
          type: string
          format: date
          description: The date the person's user account was created (`YYYY-MM-DD`)
            — not an HR hire date.
        status:
          type: string
          enum:
          - Active
          - Inactive
          description: Active while the person has an active membership in this business
            on an active account; Inactive once they have left it, whatever other
            organizations they still belong to.
        custom_fields:
          type: array
          description: The organization's own profile fields (People › Profile Fields)
            on this person that the caller may see — a coworker only those made visible
            to everyone. Absent from the short card of someone who has left.
          items:
            description: |
              One of the organization's own profile fields (People › Profile Fields)
              on a person, as the caller may see it there: an administrator sees every
              field; the person, their manager and coworkers see the fields the
              field's visibility gives them. Section headings carry no answer and are
              not listed.
            type: object
            required:
            - field_name
            - label
            - field_type
            - category
            - value
            - display_value
            - required
            - editable
            properties:
              field_name:
                type: string
                example: tshirt_size
              label:
                type: string
                example: T-shirt size
              field_type:
                type: string
                description: One of text, textarea, number, email, phone, url, select,
                  multiselect, radio, checkbox, date, datetime, time, file, currency,
                  rating.
                example: select
              category:
                type: string
                description: The profile section the field sits in.
                example: additional_info
              value:
                nullable: true
                description: The stored answer (a string, or an array for a multiselect);
                  null when unanswered.
                example: L
              display_value:
                type: string
                nullable: true
                description: The answer as the profile shows it (a date formatted,
                  a checkbox Yes/No, a rating as stars); null when unanswered.
                example: L
              required:
                type: boolean
              editable:
                type: boolean
                description: Whether the caller may change this answer.
        email:
          type: string
          nullable: true
          description: Only for a browser session or a token holding `read:users`,
            and only where the People directory shows email to the caller; left out
            otherwise.
        phone:
          type: string
          nullable: true
          description: Only for a browser session or a token holding `read:users`,
            and only where the People directory shows phone to the caller; left out
            otherwise.
    EmployeesScheduleShift:
      type: object
      required:
      - id
      - date
      - start_time
      - end_time
      - location
      - role
      - status
      properties:
        id:
          type: integer
        date:
          type: string
          format: date
          description: The shift's start date in its location's time zone.
        start_time:
          type: string
          description: "`HH:MM`, 24-hour, in the shift location's time zone."
          example: '09:00'
        end_time:
          type: string
          nullable: true
          description: "`HH:MM`, 24-hour, in the shift location's time zone."
          example: '17:00'
        location:
          type: string
          nullable: true
          description: The location's name; null for a virtual location or none.
        role:
          type: string
          nullable: true
          description: The role type of the shift's first role requirement, if any.
        status:
          type: string
          enum:
          - scheduled
          - completed
          - cancelled
          - late_reported
          - absence_reported
    EmployeesScopeError:
      description: The error envelope; a refusal that names its own `code` (the scope
        refusal, the colleague-schedule refusal) also repeats it at the top level.
      allOf:
      - "$ref": "#/components/schemas/Error"
      - type: object
        properties:
          code:
            type: string
            example: insufficient_permissions
    EsignatureRequestDetail:
      type: object
      description: One signature request as its detail view returns it. `signers`,
        `fields` and `audit_trail` list the request's signers in signing order, its
        fields and its history; a decline reason appears only to the people who answer
        for the request and to the signer who declined.
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          example: voided
        status_display:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        document_count:
          type: integer
        document_names:
          type: array
          items:
            type: string
        signing_progress: {}
        can_edit:
          type: boolean
        can_send:
          type: boolean
        can_void:
          type: boolean
          description: Whether this caller may void the request — false for a token
            without `destructive:esignature`.
        can_resend:
          type: boolean
        signed_document_url:
          type: string
          nullable: true
          description: A download link for the signed document, valid for two hours;
            null until the request is completed, and null when `signed_document_sealed`.
        signed_document_sealed:
          type: boolean
        certificate_url:
          type: string
          nullable: true
        scheduled:
          type: boolean
        scheduled_send_at:
          type: string
          format: date-time
          nullable: true
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
        signers:
          type: array
          items:
            "$ref": "#/components/schemas/EsignatureSignerDetail"
        fields:
          type: array
          items:
            type: object
        cc_recipients:
          type: array
          description: People who receive the signed copy without signing. Each is
            emailed the signed PDF once the request completes (attached unless it
            prints protected personal numbers or is over 10 MB).
          items:
            type: object
            properties:
              name:
                type: string
                nullable: true
              email:
                type: string
                format: email
        cc_copies_sent_at:
          type: string
          format: date-time
          nullable: true
          description: When the signed copies were sent to `cc_recipients`; null until
            then.
        audit_trail:
          type: array
          items:
            "$ref": "#/components/schemas/EsignatureAuditEvent"
    EsignatureSignerSummary:
      type: object
      description: One signer on a request, in signing order position `signing_order`.
      required:
      - id
      - name
      - email
      - signs_in_app
      - status
      - status_label
      - closed
      - signing_order
      - role
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          nullable: true
          description: Null for a member who signs in the app with no email on file
            — their own policy, or a document sent to them in the app (e.g. a meal
            waiver); `signs_in_app` is then true.
        signs_in_app:
          type: boolean
          description: True for a member with no email on file, who signs while signed
            in to the app, never by an emailed link.
        status:
          type: string
          enum:
          - pending
          - sent
          - viewed
          - signed
          - declined
          description: The signer row's stored routing status. Ending a request (voided,
            declined, expired) does not change it — read `closed`.
        status_label:
          type: string
          example: Closed
          description: 'The status as the request page shows it: `Closed` when `closed`,
            otherwise the status as a word (`Sent`, `Viewed`, `Signed`).'
        closed:
          type: boolean
          description: 'True when the request was voided, declined or expired before
            this signer signed or declined: nobody can sign it any more.'
        signing_order:
          type: integer
          minimum: 1
        role:
          type: string
          nullable: true
          description: The signer's role on the request (e.g. `signer`, `approver`,
            `witness`, `cc`).
    EsignatureSignerDetail:
      description: One signer as a request's detail view returns it.
      allOf:
      - "$ref": "#/components/schemas/EsignatureSignerSummary"
      - type: object
        required:
        - is_turn
        - internal
        properties:
          is_turn:
            type: boolean
            description: Whether this signer can sign now (everyone before them in
              the signing order has signed); always false once `closed`.
          internal:
            type: boolean
            description: True for a member of the organization.
          sent_at:
            type: string
            format: date-time
            nullable: true
          viewed_at:
            type: string
            format: date-time
            nullable: true
          signed_at:
            type: string
            format: date-time
            nullable: true
          declined_at:
            type: string
            format: date-time
            nullable: true
          decline_reason:
            type: string
            nullable: true
            description: The reason given on declining; null except to the people
              who answer for the request and to the signer who declined.
    EsignatureAuditEvent:
      type: object
      description: One entry of a request's history, newest last.
      required:
      - event
      - label
      - timestamp
      - user_email
      - details
      - detail_pairs
      properties:
        event:
          type: string
          example: reminder_sent
          description: The stored event key.
        label:
          type: string
          example: Reminder sent
          description: The event as the CSV export and the signing certificate name
            it.
        timestamp:
          type: string
          format: date-time
        user_email:
          type: string
          nullable: true
          description: Who did it; `system` for an event the platform originated,
            `unknown` when not recorded.
        details:
          type: object
          nullable: true
          additionalProperties: true
          description: The event's stored details, without internal row ids. A decline
            reason is withheld from anyone who may not see it, and the provider-reported
            IP address and browser from anyone outside the signing certificate's audience.
        detail_pairs:
          type: array
          description: The same details as the request page draws them — readable
            labels and values.
          items:
            type: object
            required:
            - label
            - value
            properties:
              label:
                type: string
                example: Channel
              value:
                type: string
                example: Email and in-app notification
    EsignatureRequestSummary:
      type: object
      description: A signature request as the lists show it. `id` opens it at `GET
        /esignature/{id}`.
      required:
      - id
      - title
      - status
      - status_display
      - created_at
      - updated_at
      - document_count
      - signing_progress
      - signer_count
      - created_by
      - signers
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - draft
          - sent
          - viewed
          - partially_signed
          - completed
          - declined
          - voided
          - expired
        status_display:
          type: string
          description: The status as a label (e.g. `Partially Signed`).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        document_count:
          type: integer
          minimum: 0
          description: How many documents the request carries.
        signing_progress:
          type: integer
          minimum: 0
          maximum: 100
          description: Percentage of signers who have signed, rounded; 0 when there
            are no signers.
        signer_count:
          type: integer
          minimum: 0
        created_by:
          type: object
          nullable: true
          description: Who created the request; null when the creator is unknown.
          required:
          - id
          - name
          - email
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
              nullable: true
        signers:
          type: array
          items:
            "$ref": "#/components/schemas/EsignatureSignerSummary"
    EsignaturePendingRequest:
      description: 'A request waiting for the caller''s signature: the request summary
        plus the caller''s own signer row on it.'
      allOf:
      - "$ref": "#/components/schemas/EsignatureRequestSummary"
      - type: object
        required:
        - my_signer_status
        - my_signing_order
        - is_my_turn
        properties:
          my_signer_status:
            type: string
            nullable: true
            enum:
            - pending
            - sent
            - viewed
            - signed
            - declined
            description: The status of the caller's signer row.
          my_signing_order:
            type: integer
            nullable: true
            description: The caller's position in the signing order.
          is_my_turn:
            type: boolean
            nullable: true
            description: 'Whether the caller can sign now: every signer before them
              in the signing order has signed. False once the caller has signed or
              declined.'
    EsignatureTemplateSummary:
      type: object
      description: A template the business can send a signature request from.
      required:
      - id
      - name
      - description
      - category
      - category_label
      - system_template
      - field_count
      - signer_count
      - signer_roles
      - usage_count
      - has_document
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
          description: One of `hr`, `legal`, `sales`, `general`, `compliance`, `onboarding`,
            `other`; null or empty when the template has none.
        category_label:
          type: string
          description: The category as a label (e.g. `HR`, `Onboarding`); `General`
            when the category is null.
        system_template:
          type: boolean
          description: True for a platform system template, false for the business's
            own.
        field_count:
          type: integer
          minimum: 0
          description: How many fields the template defines.
        signer_count:
          type: integer
          minimum: 0
          description: How many signer roles the template defines.
        signer_roles:
          type: array
          items:
            type: string
          description: The signer role names, in order.
        usage_count:
          type: integer
          minimum: 0
          description: How many requests have been sent from the template.
        has_document:
          type: boolean
          description: Whether the template carries a reference PDF.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ExportFilesExportFile:
      type: object
      description: An export (every column of the row).
      required:
      - id
      - business_id
      - user_id
      - export_type
      - format
      - status
      - job_id
      - file_name
      - content_type
      - file_size
      - download_url
      - expires_at
      - metadata
      - started_at
      - completed_at
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        business_id:
          type: integer
        user_id:
          type: integer
          nullable: true
          description: Who started it; null for an export with no recorded requester
            (visible to admins only).
        export_type:
          type: string
          enum:
          - shifts
          - users
          - attendance_records
          - leave_requests
          - shift_assignments
          - assignments
          - employees
        format:
          type: string
          enum:
          - csv
          - json
          - xlsx
          description: File format. JSON is an array of objects keyed by the column
            names in snake_case; XLSX stops at 100,000 rows.
        status:
          type: string
          enum:
          - pending
          - processing
          - completed
          - failed
          - cancelled
        job_id:
          type: string
          format: uuid
        file_name:
          type: string
          nullable: true
          description: Set once the file exists, e.g. "shifts_20261010_101500.csv".
        content_type:
          type: string
          nullable: true
        file_size:
          type: integer
          nullable: true
          description: Bytes.
        download_url:
          type: string
          nullable: true
          description: Once completed, the path of GET /export_files/{id}/download
            — call it for the signed file link. Null before.
          example: "/api/v1/export_files/41/download"
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: Seven days after completion; the file can no longer be downloaded
            after it.
        metadata:
          type: object
          nullable: true
          description: 'Bookkeeping: `shift_oversight` (a shifts export: the reach
            it was narrowed to), `record_count` (rows written, once completed) and
            `error` (a plain-language reason, once failed). An export started before
            2026-10-10 may also carry the `filters` / `options` a typed route used
            to store; none was ever applied.'
          properties:
            shift_oversight:
              type: object
              properties:
                covers_all:
                  type: boolean
                location_ids:
                  type: array
                  items:
                    type: integer
                group_ids:
                  type: array
                  items:
                    type: integer
                user_ids:
                  type: array
                  items:
                    type: integer
            record_count:
              type: integer
            error:
              type: string
              example: The export timed out before it finished. Please request it
                again.
        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
        updated_at:
          type: string
          format: date-time
    ExportFilesTypedRequest:
      type: object
      description: 'Optional, in the JSON body or the query string. Rails also fills
        `format` from a path extension, so POST /export_files/shifts.json exports
        JSON whatever the body says. The typed routes take nothing else: the file
        holds every row of its type (a shifts export, the shifts the requester oversees),
        and a non-empty `filters` or `options` is refused with `422 unsupported_parameter`.'
      properties:
        format:
          type: string
          enum:
          - csv
          - json
          - xlsx
          default: csv
          description: Blank means csv; any other value is refused (422 "Format is
            not included in the list").
    ExportFilesForbidden:
      description: The error envelope. A `forbidden` refusal also repeats its sentence
        as a top-level `message`; an `insufficient_permissions` one does not.
      allOf:
      - "$ref": "#/components/schemas/Error"
      - type: object
        properties:
          message:
            type: string
            example: Exporting business-wide records requires manager access
    ExportFilesValidationMessages:
      type: object
      description: A refused save, as sentences a person can be shown.
      required:
      - errors
      properties:
        errors:
          type: array
          items:
            type: string
          example:
          - Export type is not included in the list
    ForecastsForecast:
      type: object
      description: A per-day staffing forecast for one location (every column of the
        row).
      required:
      - id
      - business_id
      - location_id
      - forecast_date
      - confidence
      - hourly_predictions
      - metadata
      - source
      - active
      - is_sample_data
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        business_id:
          type: integer
        location_id:
          type: integer
        forecast_date:
          type: string
          format: date
          description: The day the forecast covers. Unique per location.
        confidence:
          type: integer
          minimum: 0
          maximum: 100
          description: Confidence in the prediction, 0–100. Defaults to 0.
        hourly_predictions:
          type: array
          nullable: true
          description: The day's per-hour predictions, as stored.
          items:
            "$ref": "#/components/schemas/ForecastsHourlyPrediction"
        metadata:
          type: object
          nullable: true
          description: Free-form JSON.
        source:
          type: string
          nullable: true
          description: Where the row came from — "api" (default) or "derived" (the
            baseline sweep). Not validated on write.
          example: api
        active:
          type: boolean
          nullable: true
          description: Only `true` is used in scheduling; null (stored when an empty
            value was sent) is off.
        is_sample_data:
          type: boolean
          description: True for a row the sample-data loader wrote.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ForecastsForecastWithLocation:
      allOf:
      - "$ref": "#/components/schemas/ForecastsForecast"
      - type: object
        required:
        - location
        properties:
          location:
            "$ref": "#/components/schemas/ForecastsLocation"
    ForecastsForecastWithSignals:
      allOf:
      - "$ref": "#/components/schemas/ForecastsForecast"
      - type: object
        required:
        - demand_signals
        properties:
          demand_signals:
            type: array
            description: The live demand signals at this location on this date, highest
              `impact_factor` first.
            items:
              "$ref": "#/components/schemas/ForecastsDemandSignal"
    ForecastsForecastDetail:
      allOf:
      - "$ref": "#/components/schemas/ForecastsForecastWithLocation"
      - type: object
        required:
        - demand_signals
        properties:
          demand_signals:
            type: array
            description: The live demand signals at this location on this date, highest
              `impact_factor` first.
            items:
              "$ref": "#/components/schemas/ForecastsDemandSignal"
    ForecastsForecastResponse:
      type: object
      required:
      - forecast
      properties:
        forecast:
          "$ref": "#/components/schemas/ForecastsForecastDetail"
        demand_signal_errors:
          type: array
          description: Present only when a nested demand-signal write was refused;
            the forecast itself was saved. One entry per refused signal.
          items:
            type: string
          example:
          - 'signal 2: Impact factor must be greater than 0'
          - 'signal 3: not found for this forecast'
    ForecastsHourlyPrediction:
      type: object
      description: One hour's prediction. Values are stored as sent. `hour` is required
        and must be 0–23; the staff counts may not be negative, and `min_staff_needed`
        may not exceed `max_staff_needed`.
      properties:
        hour:
          type: integer
          minimum: 0
          maximum: 23
        predicted_volume:
          type: number
        min_staff_needed:
          type: integer
        optimal_staff_needed:
          type: integer
        max_staff_needed:
          type: integer
    ForecastsForecastInput:
      type: object
      description: The forecast fields a create or update may set. Any other key is
        ignored.
      properties:
        location_id:
          type: integer
          description: One of the business's locations (404 "Location not found" otherwise).
            Required on create; on update a blank value is not checked here and fails
            the save.
        forecast_date:
          type: string
          format: date
        confidence:
          type: integer
          minimum: 0
          maximum: 100
        source:
          type: string
          description: Defaults to "api".
        active:
          type: boolean
          nullable: true
          description: Defaults to true. On update, anything that does not read as
            true switches the forecast off (and asks `destructive:scheduling` when
            it was on).
        hourly_predictions:
          type: array
          description: Replaces the stored list. Only the five keys shown are kept
            on each element.
          items:
            "$ref": "#/components/schemas/ForecastsHourlyPrediction"
        metadata:
          type: object
          description: Free-form JSON; replaces the stored value.
    ForecastsNestedDemandSignalInput:
      type: object
      description: A demand signal written with a forecast; its location and date
        are the forecast's. A new signal needs `signal_type` and — unless the type
        is "weather" — `name`.
      properties:
        id:
          type: integer
          description: Update this signal (update only; ignored on create). It must
            be one of the forecast's live signals.
        signal_type:
          type: string
          enum:
          - event
          - weather
          - holiday
          - promotion
          - sale
          - seasonal
          - external
        name:
          type: string
        description:
          type: string
        impact_factor:
          type: number
          description: Multiplier on baseline demand, greater than 0 (1.0 = no effect,
            the default for a new signal).
        value:
          type: number
          description: Older name for `impact_factor`, used when `impact_factor` is
            blank.
        signal_time:
          type: string
          format: date-time
        active:
          type: boolean
          nullable: true
          description: A null is ignored. A present value that does not read as true
            switches the signal off.
        metadata:
          type: object
    ForecastsDemandSignal:
      type: object
      description: A demand signal as the forecast endpoints return it (without its
        location row).
      required:
      - id
      - business_id
      - location_id
      - signal_type
      - signal_date
      - signal_time
      - impact_factor
      - name
      - description
      - metadata
      - active
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        business_id:
          type: integer
        location_id:
          type: integer
        signal_type:
          type: string
          enum:
          - event
          - weather
          - holiday
          - promotion
          - sale
          - seasonal
          - external
        signal_date:
          type: string
          format: date
        signal_time:
          type: string
          format: date-time
          nullable: true
        impact_factor:
          type: string
          description: Multiplier on baseline demand, as a decimal string (e.g. "1.35").
          example: '1.35'
        name:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        metadata:
          type: object
          nullable: true
        active:
          type: boolean
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ForecastsLocation:
      type: object
      description: The forecast's location — every column of the location row; the
        common ones are listed. Decimal columns (budgets, costs, latitude, longitude,
        weekly_contracted_hours) arrive as decimal strings.
      additionalProperties: true
      properties:
        id:
          type: integer
        business_id:
          type: integer
        name:
          type: string
        address:
          type: string
        city:
          type: string
          nullable: true
        state:
          type: string
          nullable: true
        zip:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        country_code:
          type: string
          nullable: true
        timezone:
          type: string
          nullable: true
        active:
          type: boolean
          nullable: true
        parent_id:
          type: integer
          nullable: true
        external_id:
          type: string
          nullable: true
        latitude:
          type: string
          nullable: true
        longitude:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ForecastsListMeta:
      type: object
      required:
      - current_page
      - total_pages
      - total_count
      properties:
        current_page:
          type: integer
        total_pages:
          type: integer
        total_count:
          type: integer
    ForecastsValidationMessages:
      type: object
      description: A refused save, as sentences a person can be shown.
      required:
      - errors
      properties:
        errors:
          type: array
          items:
            type: string
          example:
          - Forecast date has already been taken
    ForecastsBulkItem:
      type: object
      description: One forecast to create, checked by the job. Unlike the single-record
        create, `hourly_predictions` and `metadata` are stored exactly as sent.
      required:
      - location_id
      - forecast_date
      properties:
        location_id:
          type: integer
          description: One of the business's locations; otherwise the item fails ("Location
            N not found in this business").
        forecast_date:
          type: string
          format: date
        hourly_predictions:
          type: array
          items:
            "$ref": "#/components/schemas/ForecastsHourlyPrediction"
        confidence:
          type: integer
          minimum: 0
          maximum: 100
        source:
          type: string
        metadata:
          type: object
        active:
          type: boolean
          nullable: true
    ForecastsBulkOptions:
      type: object
      properties:
        conflict_resolution:
          type: string
          enum:
          - skip
          - overwrite
          - fail
          default: skip
          description: What an item does when a forecast already exists for its location
            and date.
        validation_mode:
          type: string
          enum:
          - strict
          - lenient
          default: strict
          description: strict stops the operation at the first failed item; lenient
            records it and continues.
    ForecastsBulkAccepted:
      type: object
      required:
      - bulk_operation
      - job_id
      - status_url
      properties:
        bulk_operation:
          "$ref": "#/components/schemas/ForecastsBulkOperation"
        job_id:
          type: string
          format: uuid
        status_url:
          type: string
          description: GET it for progress (GET /bulk_operations/status/{job_id}).
          example: "/api/v1/bulk_operations/status/0b6f1c1e-3c55-4f0b-9d0e-6a3c1f2f9a10"
    ForecastsBulkOperation:
      type: object
      description: The bulk operation row (every column).
      properties:
        id:
          type: integer
        business_id:
          type: integer
        user_id:
          type: integer
          nullable: true
          description: Null — this door does not record who started it.
        operation_type:
          type: string
          example: create
        data_type:
          type: string
          example: forecasts
        status:
          type: string
          enum:
          - pending
          - processing
          - completed
          - failed
          - cancelled
        metadata:
          type: object
          description: The submitted `items` and `options`, as the job will read them.
          properties:
            items:
              type: array
              items:
                type: object
            options:
              type: object
        total_items:
          type: integer
        processed_items:
          type: integer
        successful_items:
          type: integer
        failed_items:
          type: integer
        error_messages:
          type: string
          nullable: true
          description: 'Newline-separated item errors, each "Item <index>: <reason>"
            (index from 0), at most 50 recorded.'
        job_id:
          type: string
          format: uuid
        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
        updated_at:
          type: string
          format: date-time
    FrontlineBlockedItem:
      type: object
      description: The obligation after the flag — the same shape the item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 18
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 12
        status:
          type: string
          description: Unchanged by the flag — a blocker is an annotation, not a transition.
          example: claimed
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: false
        blocked:
          type: boolean
          description: True once flagged — the key that tells a client the flag took.
          example: true
        blocked_reason:
          type: string
          nullable: true
          description: The structured cause that was recorded.
          example: no_stock
        blocked_note:
          type: string
          nullable: true
          description: The free-text detail, if any.
          example: Fridge 2 alarm is sounding, panel reads ERR.
        blocked_at:
          type: string
          format: date-time
          nullable: true
    FrontlineBlockError:
      type: object
      description: The standard error envelope for a refused flag.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`invalid_reason`, `not_assigned`, `already_done` or `in_review`."
              example: invalid_reason
            message:
              type: string
              example: Pick what's blocking this work.
    FrontlineCampaignDetailLocation:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
    FrontlineCampaignDetailCourse:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        openable:
          type: boolean
          description: Whether the caller can open the Training app (the page links
            the course only then).
    FrontlineCampaignDetail:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
              enum:
              - draft
              - scheduled
              - active
              - closed
            status_label:
              type: string
            priority:
              type: string
              enum:
              - normal
              - important
              - critical
            priority_label:
              type: string
            work_type:
              type: string
            work_type_label:
              type: string
              description: Floor-facing name ("Post" for an acknowledgement).
            starts_on:
              type: string
              format: date
              nullable: true
            ends_on:
              type: string
              format: date
              nullable: true
            releases_on:
              type: string
              format: date
              nullable: true
              description: The release date shown beside a scheduled badge.
            launched:
              type: boolean
            launched_at:
              type: string
              format: date-time
              nullable: true
            closed_at:
              type: string
              format: date-time
              nullable: true
        viewer:
          type: object
          properties:
            author_tier:
              type: boolean
            edit_mode:
              type: string
              nullable: true
              enum:
              - draft
              - live
              description: '"live" — editing a launched campaign asks whether the
                change is a correction or material. Null when the caller cannot edit
                right now (outside their span, or while a background launch is still
                writing the work).'
            can:
              type: object
              properties:
                view_coverage:
                  type: boolean
                  description: Launched, Coverage on, author tier.
                view_history:
                  type: boolean
                edit:
                  type: boolean
                  description: May open the edit form now. False while a background
                    launch is still writing the work (release state `launching`).
                edit_translations:
                  type: boolean
                clone:
                  type: boolean
                close:
                  type: boolean
                delete:
                  type: boolean
                  description: Drafts only, and not while a background launch is still
                    writing the work.
                ask_question:
                  type: boolean
                withdraw_approval:
                  type: boolean
                  description: 'The page''s Withdraw from approval control: not launched,
                    a publication request is open, and the caller is in the submitter''s
                    tier.'
        release:
          type: object
          nullable: true
          description: The header's one primary action. Null once launched, for a
            campaign that is not draft or scheduled, or for a caller outside the author
            tier (a collaborator). While a background launch is fanning out the state
            is `launching` (disabled). An author whose span misses a target gets state
            `outside_span` (disabled, with `blocked_reason`); the reason offers submitting
            for approval only when this caller may submit.
          properties:
            state:
              type: string
              enum:
              - launching
              - under_review
              - approval_workflow_missing
              - submit_for_approval
              - outside_span
              - no_targets
              - launch_refused
              - launch_with_reason
              - launch
            action:
              type: string
              nullable: true
              enum:
              - submit_for_approval
              - launch
            enabled:
              type: boolean
            blocked_reason:
              type: string
              nullable: true
              description: Why the control is disabled, in words to show beside it.
            requires_reason:
              type: boolean
              description: Launching over the load warning needs a reason recorded
                on the history.
            reason_min_length:
              type: integer
              nullable: true
            locations_outside_span:
              type: integer
              nullable: true
            refusal_code:
              type: string
              nullable: true
              description: 'On `launch_refused` only: which launcher rule refuses
                the launch (for example `no_inspection_template`, `no_single_assignee`,
                a stale workforce source). `blocked_reason` carries the words.'
        approval:
          type: object
          nullable: true
          description: Pre-launch only.
          properties:
            required:
              type: boolean
              description: The tenant requires publication approval.
            state:
              type: string
              nullable: true
              enum:
              - approved
              - under_review
              - workflow_missing
              - changes_requested
              - approval_stale
              - required
              description: "`changes_requested` — the latest request was sent back;
                `approval_stale` — approved, then edited, so it needs approval again."
            sent_back:
              type: object
              nullable: true
              description: 'Present on `changes_requested`: who sent the latest request
                back and their comment.'
              properties:
                by:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                comment:
                  type: string
                  nullable: true
            stale_reason:
              type: string
              nullable: true
              description: 'Present on `approval_stale`: the sentence the page shows.
                It asks the caller to submit again only when this caller holds the
                Submit button (release state `submit_for_approval`).'
        coverage:
          type: object
          nullable: true
          description: Launched only; span-bounded.
          properties:
            unit:
              type: string
              enum:
              - people
              - locations
              description: What the headline tile counts. The other tiles always count
                locations.
            total:
              type: integer
            done:
              type: integer
              nullable: true
              description: Null while `people_withheld` is present.
            in_progress:
              type: integer
              nullable: true
              description: Null while `people_withheld` is present.
            not_started:
              type: integer
              nullable: true
              description: Null while `people_withheld` is present.
            missed:
              type: integer
              nullable: true
              description: Null while `people_withheld` is present.
            blocked:
              type: integer
            unassigned:
              type: integer
              nullable: true
              description: Null while `people_withheld` is present.
            percent:
              type: integer
              nullable: true
              description: Null while `people_withheld` is present.
            people:
              type: object
              nullable: true
              description: Posts (acknowledgements) only; null while `people_withheld`
                is present.
              properties:
                expected:
                  type: integer
                confirmed:
                  type: integer
            people_withheld:
              type: object
              description: Knowledge Layer X3 (#4546) — present only on a post a works
                council covers whose caller's whole span holds fewer people than the
                company's smallest report group. `unit` stays `people`, `people` and
                the status counts are null; show `reason` in the headline tile instead.
              properties:
                reason:
                  type: string
                  example: Fewer than 5 people — counted in the level above.
        launch_gaps:
          type: object
          nullable: true
          description: The launch-time snapshot of what the launch left out; null
            when nothing was.
          properties:
            skipped_count:
              type: integer
              description: Targeted locations that received nothing — the caller's
                own locations when the launch recorded every location by name, else
                the campaign-wide count (`skipped_campaign_wide`).
            skipped_campaign_wide:
              type: boolean
              description: The launch's stored list was cut short, so `skipped_count`
                is the campaign's and may include locations outside the caller's area.
            skipped:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  reason:
                    type: string
                  reason_label:
                    type: string
            skipped_overflow:
              type: integer
              description: The "…and N others" after the named list; 0 when nothing
                is listed or the count is campaign-wide.
            unstaffed_count:
              type: integer
              description: Targeted locations with nobody assigned — the same span
                rule as `skipped_count`.
            unstaffed_campaign_wide:
              type: boolean
            unstaffed:
              type: array
              items:
                "$ref": "#/components/schemas/FrontlineCampaignDetailLocation"
            unstaffed_overflow:
              type: integer
              description: The same rule as `skipped_overflow`.
            recorded_at:
              type: string
              format: date-time
              nullable: true
        required_training:
          type: object
          nullable: true
          description: Launched and training-gated only.
          properties:
            courses:
              type: array
              items:
                "$ref": "#/components/schemas/FrontlineCampaignDetailCourse"
            holders_total:
              type: integer
            holders_complete:
              type: integer
            pending_count:
              type: integer
            pending_names:
              type: array
              items:
                type: string
              description: At most five.
            pending_overflow:
              type: integer
            all_complete:
              type: boolean
        readiness:
          type: object
          nullable: true
          properties:
            ready:
              type: boolean
            warnings:
              type: array
              items:
                type: object
                properties:
                  code:
                    type: string
                  message:
                    type: string
            availability:
              type: object
              properties:
                applicable:
                  type: boolean
                checked:
                  type: boolean
                  description: False above the inline size bound — then say who-can-take-it
                    was not checked.
                failed:
                  type: boolean
        writing_check:
          type: object
          nullable: true
          description: Pre-launch, AI on, lifecycle tier only.
          properties:
            measured_against:
              type: string
              enum:
              - content_standards
              - clarity_baseline
            result:
              type: object
              nullable: true
              properties:
                verdict:
                  type: string
                reads_well:
                  type: boolean
                notes:
                  type: string
                  nullable: true
                dead_links:
                  type: array
                  items:
                    type: string
        targets:
          type: object
          nullable: true
          description: The reviewable roster (pre-launch). Names are span-bounded
            and capped; counts are true totals.
          properties:
            mode:
              type: string
              enum:
              - locations
              - audience
              - everywhere
            mode_label:
              type: string
            count:
              type: integer
            not_targeted_count:
              type: integer
              nullable: true
              description: Audience mode only.
            concerns:
              type: boolean
            locations:
              type: array
              items:
                "$ref": "#/components/schemas/FrontlineCampaignDetailLocation"
            locations_overflow:
              type: integer
            dropped_count:
              type: integer
            dropped:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  reason:
                    type: string
                  explanation:
                    type: string
            dropped_overflow:
              type: integer
            unstaffed_count:
              type: integer
            unstaffed:
              type: array
              items:
                "$ref": "#/components/schemas/FrontlineCampaignDetailLocation"
            unstaffed_overflow:
              type: integer
        audience_load:
          type: object
          nullable: true
          description: The overload gauge (pre-launch).
          properties:
            tier:
              type: string
              enum:
              - none_targeted
              - over_capacity
              - approaching_capacity
              - has_room
            overloaded:
              type: boolean
            window_days:
              type: integer
            headcount:
              type: integer
            per_person:
              type: number
            total_open_items:
              type: integer
            capacity:
              type: number
            counts:
              type: object
              properties:
                campaign_items:
                  type: integer
                tasks:
                  type: integer
                must_reads:
                  type: integer
                training_due:
                  type: integer
            scheduled_minutes:
              type: integer
              nullable: true
              description: Null (not 0) when Scheduling can't answer.
            estimated_minutes_total:
              type: integer
            unestimated_items:
              type: integer
            degraded_sources:
              type: array
              items:
                type: string
            recommendation:
              type: string
            computed_at:
              type: string
              format: date-time
              nullable: true
        brief:
          type: object
          properties:
            instructions_html:
              type: string
              nullable: true
              description: Sanitized rich text.
            instructions_text:
              type: string
              nullable: true
            checklist:
              type: array
              items:
                type: string
            documents:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  filename:
                    type: string
                  byte_size:
                    type: integer
                  content_type:
                    type: string
                  url:
                    type: string
                    description: Signed inline URL, valid for one hour.
        setup:
          type: object
          description: Every row of "How this campaign is set up".
          properties:
            work:
              type: object
              properties:
                type:
                  type: string
                label:
                  type: string
                description:
                  type: string
                template_label:
                  type: string
                  nullable: true
                inspection:
                  type: object
                  nullable: true
                  properties:
                    checks:
                      type: integer
                    critical:
                      type: integer
                    needing_photo:
                      type: integer
                    pass_threshold:
                      type: number
                      nullable: true
                    require_signature:
                      type: boolean
            assignment:
              type: object
              properties:
                strategy:
                  type: string
                label:
                  type: string
                timing:
                  type: string
                  enum:
                  - each_shift
                  - on_pickup
                  - at_launch
                timing_label:
                  type: string
            targeting_label:
              type: string
            owning_department:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
            estimated_duration_minutes:
              type: integer
              nullable: true
            priority:
              type: string
            priority_label:
              type: string
            recurrence:
              type: object
              nullable: true
              properties:
                pattern:
                  type: string
                label:
                  type: string
                until:
                  type: string
                  format: date
                  nullable: true
                on_new_occurrence:
                  type: string
                  nullable: true
                every_shift:
                  type: object
                  nullable: true
                  properties:
                    per:
                      type: string
                      enum:
                      - assignee
                      - location
                    offset_minutes:
                      type: integer
                      description: Negative = before the anchor.
                    anchor:
                      type: string
                      enum:
                      - start
                      - end
            occurrence_of:
              type: object
              nullable: true
              properties:
                campaign_id:
                  type: integer
                occurrence_on:
                  type: string
                  format: date
                  nullable: true
            eligibility_window:
              type: object
              nullable: true
              properties:
                label:
                  type: string
            on_miss:
              type: object
              properties:
                raises_corrective_action:
                  type: boolean
            on_unfulfilled:
              type: object
              properties:
                value:
                  type: string
                label:
                  type: string
            close_behavior:
              type: object
              properties:
                value:
                  type: string
                label:
                  type: string
            proof:
              type: object
              properties:
                captured_in_inspection:
                  type: boolean
                photo:
                  type: boolean
                signature:
                  type: boolean
            review:
              type: object
              properties:
                mode:
                  type: string
                  enum:
                  - workflow
                  - workflow_unavailable
                  - single_reviewer
                  - none
                workflow:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    stages:
                      type: integer
            certifications:
              type: array
              items:
                type: object
                properties:
                  skill_id:
                    type: integer
                    nullable: true
                  name:
                    type: string
                    nullable: true
                  available:
                    type: boolean
                  minimum_proficiency:
                    type: integer
                  require_verified:
                    type: boolean
                  jurisdiction:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                        nullable: true
                      available:
                        type: boolean
            required_response:
              type: object
              nullable: true
              properties:
                kind:
                  type: string
                  enum:
                  - form
                  - survey
                source_id:
                  type: integer
                  nullable: true
                name:
                  type: string
                  nullable: true
                available:
                  type: boolean
                per_work_item:
                  type: boolean
            required_training:
              type: array
              items:
                "$ref": "#/components/schemas/FrontlineCampaignDetailCourse"
        shift_attention_reason:
          type: string
          nullable: true
        upcoming_shift_work:
          type: object
          nullable: true
          description: Every-shift schedules only — the next seven days at the caller's
            locations.
          properties:
            error:
              type: string
              nullable: true
            shifts:
              type: array
              items:
                type: object
                properties:
                  shift_id:
                    type: integer
                  location:
                    "$ref": "#/components/schemas/FrontlineCampaignDetailLocation"
                  time_zone:
                    type: string
                  starts_at:
                    type: string
                    format: date-time
                  due_at:
                    type: string
                    format: date-time
                  work_items:
                    type: integer
        questions:
          type: object
          properties:
            post_count:
              type: integer
              description: Questions and replies.
            question_count:
              type: integer
            accepting_questions:
              type: boolean
            can_post:
              type: boolean
            max_body_length:
              type: integer
            threads:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  body:
                    type: string
                  author:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                      photo_url:
                        type: string
                        description: The author's avatar — uploaded profile photo,
                          else a generated initials avatar. Always absolute.
                  is_campaign_owner:
                    type: boolean
                  parent_id:
                    type: integer
                    nullable: true
                  created_at:
                    type: string
                    format: date-time
                  edited:
                    type: boolean
                  replies:
                    type: array
                    description: The replies, oldest first — each the same shape as
                      a question (author carries photo_url too), without its own replies.
                    items:
                      type: object
            meta:
              type: object
              properties:
                showing:
                  type: integer
                total:
                  type: integer
                truncated:
                  type: boolean
    FrontlineExecutionCampaignsResult:
      type: object
      properties:
        campaigns:
          type: array
          items:
            "$ref": "#/components/schemas/FrontlineExecutionCampaignSummary"
        meta:
          "$ref": "#/components/schemas/FrontlineExecutionCampaignsMeta"
      required:
      - campaigns
      - meta
    FrontlineExecutionCampaignSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          description: One of draft/scheduled/active/closed.
        priority:
          type: string
        assignment_strategy:
          type: string
        starts_on:
          type: string
          format: date
          nullable: true
        ends_on:
          type: string
          format: date
          nullable: true
        coverage:
          "$ref": "#/components/schemas/FrontlineExecutionCampaignCoverage"
      required:
      - id
      - name
      - status
      - coverage
    FrontlineExecutionCampaignCoverage:
      type: object
      description: This campaign's obligations rolled up by status, bounded to the
        caller's location span.
      properties:
        total:
          type: integer
          description: Every obligation for this campaign in the caller's span.
        done:
          type: integer
          nullable: true
          description: Completed obligations.
        in_progress:
          type: integer
          nullable: true
          description: Obligations actively being worked — claimed, in progress, submitted
            for review, or reopened.
        awaiting_review:
          type: integer
          nullable: true
          description: The subset of `in_progress` submitted and waiting on a reviewer.
            Additive — `in_progress` still counts it. Same value as the Coverage endpoint's
            `awaiting_review` for this campaign.
        not_started:
          type: integer
          nullable: true
          description: Obligations still open (unstarted), assigned or not.
        missed:
          type: integer
          nullable: true
          description: Obligations whose window closed with no completion.
        blocked:
          type: integer
          description: Obligations a worker has flagged as blocked (any status). Not
            a status bucket, so it overlaps the others. Same value as the Coverage
            endpoint's `blocked` for this campaign.
        unassigned:
          type: integer
          nullable: true
          description: The subset of `not_started` with no holder — open work still
            in the claim pool.
        percent:
          type: integer
          nullable: true
          description: 'How far along the campaign is, as a 0–100 whole number, in
            the `unit` it names. For a post (`unit: people`) it is the share of its
            people who confirmed it at the caller''s stores (`people.confirmed` ÷
            `people.expected`; 100 when nobody is expected) — the figure its own Coverage
            page heads with, not done ÷ total (a post''s store is done only once everyone
            there has confirmed). For every other campaign it is done ÷ total (0 when
            total is 0).'
        unit:
          type: string
          enum:
          - people
          - locations
          description: What `percent` counts — `people` for a post with work in the
            caller's span, else `locations`. The status counts always count obligations
            (one per location). Additive.
        people:
          type: object
          nullable: true
          description: A post's "N of M people confirmed" pair at the caller's stores,
            the numbers `percent` is drawn from. Null for every other work type, for
            a post with no work in the span, and while `people_withheld` is present.
            Additive.
          properties:
            expected:
              type: integer
              description: The people the post asks to confirm it at these stores.
            confirmed:
              type: integer
              description: Those of them who have confirmed it.
        people_withheld:
          type: object
          description: Knowledge Layer X3 (#4546) — present only on a post a works
            council covers whose caller's whole span holds fewer people than the company's
            smallest report group. `done`, `in_progress`, `awaiting_review`, `not_started`,
            `missed`, `unassigned`, `percent` and `people` are then null; show `reason`
            instead.
          properties:
            reason:
              type: string
              example: Fewer than 5 people — counted in the level above.
      required:
      - total
      - done
      - in_progress
      - awaiting_review
      - not_started
      - missed
      - blocked
      - unassigned
      - percent
      - unit
      - people
    FrontlineExecutionCampaignsMeta:
      type: object
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
        status_filter_ignored:
          type: boolean
          description: Present and true only when a `status` param was sent but not
            applied (non-scalar or outside the allowed set).
        status_filter_note:
          type: string
          description: Human-readable explanation, present alongside status_filter_ignored.
      required:
      - current_page
    FrontlineChecklistItemResult:
      type: object
      properties:
        item:
          type: object
          description: The obligation after the tick — the same shape the item-action
            endpoints return.
          properties:
            id:
              type: integer
              example: 18
            campaign_id:
              type: integer
              example: 42
            title:
              type: string
              example: Reset the seasonal endcap
            status:
              type: string
              example: in_progress
            location:
              type: string
              nullable: true
              example: Fourth Street
            location_id:
              type: integer
              nullable: true
              example: 12
        checklist_item_id:
          type: integer
          description: The step that was just updated.
          example: 91
        completed:
          type: boolean
          description: The step's state after this call.
          example: true
        all_completed:
          type: boolean
          description: True when every step on the task is now done — the cue to offer
            Complete.
          example: false
        task_status:
          type: string
          description: The minted checklist Task's status after the tick.
          example: in_progress
        checklist:
          type: object
          description: The full, freshly-read checklist — identical to the `checklist`
            block on GET /items/{id}.
          properties:
            total:
              type: integer
              example: 3
            completed:
              type: integer
              example: 1
            percentage:
              type: number
              format: float
              example: 33.3
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    example: 91
                  title:
                    type: string
                    example: Face and date the shelf
                  description:
                    type: string
                    nullable: true
                  sort_order:
                    type: integer
                    example: 0
                  completed:
                    type: boolean
                    example: true
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  completed_by:
                    type: string
                    nullable: true
                    example: Dana Holder
                  requires_photo:
                    type: boolean
                    example: false
                  requires_notes:
                    type: boolean
                    example: false
        warnings:
          type: array
          nullable: true
          description: Present only when the step ticked but the parent Task could
            not auto-complete.
          items:
            type: string
    FrontlineChecklistItemError:
      type: object
      description: The standard error envelope for a refused tick.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`no_checklist`, `task_finished`, `requires_notes`, `requires_photo`,
                `photo_failed` or `save_failed`."
              example: requires_photo
            message:
              type: string
              example: This checklist item requires a photo to be marked as complete.
    ClaimedItem:
      type: object
      description: The item after a successful claim — the same shape the item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`claimed` after a successful claim."
          example: claimed
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
          description: Unchanged by the claim — the obligation still falls due when
            it did.
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        claimable:
          type: boolean
          description: Always `false` on a claimed item — it is no longer in the pool.
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        blocked:
          type: boolean
          example: false
        assignment_explanation:
          type: object
          nullable: true
          description: Why the caller now holds this work (recorded from the claim
            that just happened).
    ClaimError:
      type: object
      description: The standard error envelope for a refused claim.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`already_assigned`, `not_claimable`, or a critical-cap
                code."
              example: already_assigned
            message:
              type: string
              example: Someone else just claimed this work.
    CompleteItem:
      type: object
      description: The item after completion — the same shape the other item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`done` when finished, or `submitted` when the campaign requires
            review."
          example: done
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        claimable:
          type: boolean
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
    CompleteError:
      type: object
      description: The standard error envelope for a refused completion.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`proof_required`, `already_done`, `missed`, `in_review`,
                `not_assigned`, `off_shift`, `off_site`, `location_required`, `surface_disabled`,
                `insufficient_permissions` or `not_found`."
              example: proof_required
            message:
              type: string
              example: Add a photo before marking this done.
    CampaignCoverage:
      type: object
      description: One campaign's coverage, span-bounded to the caller.
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
              description: The campaign's lifecycle status
              e.g. active/closed.:
            priority:
              type: string
              enum:
              - normal
              - important
              - critical
              description: The campaign's urgency band.
            work_type:
              type: string
              enum:
              - task
              - inspection
              - acknowledgement
              description: What KIND of work this campaign is.
            work_type_label:
              type: string
              description: The floor-facing label for `work_type` — "Task", "Inspection"
                or "Post" (an acknowledgement).
          required:
          - id
          - name
          - status
          - priority
          - work_type
          - work_type_label
        summary:
          type: object
          description: Counted in SQL over the WHOLE campaign inside the caller's
            span — the Coverage summary card the campaign-detail screen shows above
            its location breakdown. While `people_withheld` is present the status
            counts and `percent` are null (a post's store is done only when everyone
            there confirmed, so they would be those few people's own answers) and
            `people_withheld` is true.
          properties:
            total:
              type: integer
            done:
              type: integer
              nullable: true
            in_progress:
              type: integer
              nullable: true
              description: claimed / in_progress / submitted / reopened
            not_started:
              type: integer
              nullable: true
              description: open obligations
            missed:
              type: integer
              nullable: true
            blocked:
              type: integer
              description: obligations flagged blocked (an annotation on top of their
                status)
            unassigned:
              type: integer
              nullable: true
              description: open obligations with no holder — still in the claim pool;
                null while `people_withheld` is present
            percent:
              type: integer
              nullable: true
              description: done ÷ total, 0–100 (0 when total is 0)
            people_withheld:
              type: boolean
              description: Present (true) only while the top-level `people_withheld`
                is.
          required:
          - total
          - done
          - in_progress
          - not_started
          - missed
          - blocked
          - unassigned
          - percent
        hierarchy:
          type: object
          description: The `?node=` tree drill — the same rollup the web
          properties:
            location:
              type: object
              nullable: true
              description: The location selected with `?node=` (the drill's parent).
                Null without a node, and null for a node outside the caller's span
                or another tenant's id. Only `hierarchy` is bounded to it — `summary`
                and `by_location` still describe the caller's whole span.
              properties:
                id:
                  type: integer
                name:
                  type: string
            breadcrumb:
              type: array
              description: The span-clipped path from the caller's span top down to
                `node`.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
            leaf_level:
              type: boolean
              description: True when `nodes` are individual stores (the tree bottomed
                out).
            nodes:
              type: array
              items:
                "$ref": "#/components/schemas/CampaignCoverageRollupNode"
        by_location:
          type: array
          description: A flat, worst-first list of the locations the campaign touches
            in the caller's span, sliced to `limit`.
          items:
            "$ref": "#/components/schemas/CampaignCoverageLocationRow"
        locations_total:
          type: integer
          description: Locations in scope BEFORE the slice.
        locations_shown:
          type: integer
          description: Rows actually returned in `by_location`.
        has_more_locations:
          type: boolean
          description: Whether `locations_total` exceeds `locations_shown` (raise
            `limit` to see more).
        names_hidden:
          type: object
          description: 'Knowledge Layer X3 — present only on an acknowledgement campaign
            whose audience a works council covers (General Settings › Countries).
            Areas and locations with fewer people than the company''s smallest report
            group are then folded into one trailing "Other — N …" row (`other: true`)
            in `hierarchy.nodes` and `by_location`, exactly as the web page does.'
          properties:
            count:
              type: integer
              nullable: true
              description: People the report leaves unnamed — null while they are
                fewer than the company's smallest report group (see `fewer_than`).
            fewer_than:
              type: integer
              description: 'Present only when `count` is null: the company''s smallest
                report group. The hidden people are "a few" — fewer than this.'
            reason:
              type: string
              example: Names are hidden for people in Germany (works council).
        people_withheld:
          type: object
          description: 'Knowledge Layer X3 (#4546) — present only when the caller''s
            whole span holds fewer people than the company''s smallest report group
            on a post a works council covers. No figure counts people then: `by_location`
            is empty, `hierarchy.nodes` holds one `withheld` node, and the summary''s
            status counts are null. Show `reason` instead.'
          properties:
            reason:
              type: string
              example: Fewer than 5 people — counted in the level above.
      required:
      - campaign
      - summary
      - hierarchy
      - by_location
      - locations_total
      - locations_shown
      - has_more_locations
    CampaignCoverageLocationRow:
      type: object
      description: One location's coverage on this campaign.
      properties:
        location_id:
          type: integer
          nullable: true
          description: Null for a row whose location has since been deleted, and for
            the folded `other` row.
        location:
          type: string
          nullable: true
          description: The location's name (null for a deleted location). On the folded
            `other` row, its label — "Other — 3 locations".
        other:
          type: boolean
          description: 'Present (true) only on the one folded row a works council
            asks for (see `names_hidden`): the small locations counted together.'
        location_count:
          type: integer
          description: On the `other` row only — how many locations it folds.
        done:
          type: integer
          description: Completed obligations at this location.
        total:
          type: integer
          description: Total obligations at this location.
        percent:
          type: integer
          description: done ÷ total for task work; confirmed ÷ expected people for
            an acknowledgement campaign. 0–100.
        status:
          type: string
          enum:
          - on_track
          - at_risk
          - behind
          description: Coverage-health band derived from `percent` — the SAME judgement
            the web coverage page announces per location. on_track ≥ 80%, at_risk
            40–79%, behind < 40%.
        accountable:
          type: string
          nullable: true
          description: The person on the hook for this location — the leading obligation's
            assignee, else the location's accountable owner. Null when neither resolves.
      required:
      - location_id
      - location
      - done
      - total
      - percent
      - status
      - accountable
    CampaignCoverageRollupNode:
      type: object
      description: One node of the `?node=` hierarchy drill, its subtree rolled up.
      properties:
        location_id:
          type: integer
          nullable: true
          description: Null on the folded `other` node.
        name:
          type: string
          nullable: true
          description: The location's name; on the `other` node its label, e.g. "Other
            — 2 areas".
        other:
          type: boolean
          description: Present (true) only on the one trailing node a works council
            folds small areas into (see `names_hidden`).
        withheld:
          type: boolean
          description: 'Present (true) only on the one node that stands for a whole
            level below the company''s smallest report group: `name` is the sentence
            to show and every figure is null.'
        location_count:
          type: integer
          description: On the `other` node only — how many areas it folds.
        level_name:
          type: string
          description: The tenant's word for this tier, e.g. District.
        leaf:
          type: boolean
          description: True when this node is an individual store.
        store_count:
          type: integer
          description: Stores under this node.
        total:
          type: integer
        done:
          type: integer
        percent:
          type: integer
        people_expected:
          type: integer
          nullable: true
          description: Audience size for an acknowledgement campaign; null otherwise.
        people_confirmed:
          type: integer
          nullable: true
          description: People who have acknowledged; null for non-acknowledgement
            work.
        inactive:
          type: boolean
          description: A deactivated location still holding work — listed so the level's
            rows add up to its parent's figures. Show an Inactive badge.
    CoverageDashboard:
      type: object
      description: The Coverage dashboard for one node and one lens.
      properties:
        location:
          type: object
          nullable: true
          description: 'The location this view is OF — the one every number in this
            response is bounded to. With `?node=` it is that node (a corporate, region,
            district or store). Without one it is the single top of the caller''s
            span: a corporate member''s corporate, a regional director''s region,
            a district manager''s district. Null for a node outside the span or another
            tenant''s id (so a location''s name never leaks past what the caller may
            open), and null when the span has several top locations (an admin with
            no location membership over a tenant with more than one root), where no
            single location is being viewed.'
          properties:
            id:
              type: integer
            name:
              type: string
        scope:
          type: object
          description: Where the viewer is, for the breadcrumb and subtitle.
          properties:
            node:
              nullable: true
              description: The current node ({id, name, level_name}); null at the
                top of the span or for a node outside it.
            breadcrumb:
              type: array
              description: The span-clipped trail from the top of the span down to
                the current node.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  level_name:
                    type: string
                    nullable: true
            child_level_name:
              type: string
              nullable: true
              description: The tenant's own word for the child tier ("District", "Region").
            child_count:
              type: integer
            sites_reporting:
              type: integer
              description: Physical stores under the current node within the span.
            tiers:
              type: array
              description: The structure beneath this view — one entry per location
                level, shallowest first, counting the child locations shown and everything
                under them inside the caller's span. A corporate view returns its
                regions, districts and stores; a region its districts and stores;
                a district its stores — so a client can frame "3 Regions · 10 Districts
                · 66 Stores". Labels are the tenant's own level names (Admin → Locations
                → Hierarchy → Levels). Active locations only; a location with no level
                is left out. Empty for a node outside the span.
              items:
                type: object
                properties:
                  level_id:
                    type: integer
                    description: The LocationLevel id.
                  level:
                    type: integer
                    description: The level's depth (0 is the top of the tenant's ladder).
                  label:
                    type: string
                    description: The level name exactly as the tenant set it
                    e.g. "District".:
                  label_plural:
                    type: string
                    description: The label's plural, e.g. "Districts".
                  count:
                    type: integer
                    description: Active locations at this level beneath the view.
              example:
              - level_id: 12
                level: 1
                label: Region
                label_plural: Regions
                count: 3
              - level_id: 13
                level: 2
                label: District
                label_plural: Districts
                count: 10
              - level_id: 14
                level: 3
                label: Store
                label_plural: Stores
                count: 66
        header:
          type: object
          description: 'The headline numbers of the lens served — the same work in
            both lenses. The status figures and `percent` count obligations (done
            ÷ total), never people, so beside a post''s card (`unit: people`) they
            count a post''s store as done only once everyone there confirmed. Under
            a works council both lenses leave every post out of the status figures
            and say so (`posts_left_out`; in the campaign lens since 2026-10-31, before
            which it counted every post whose card showed figures). In the campaign
            lens `people_withheld` also says how many of those posts have their card
            withheld; the nodes lens never carries it.'
          properties:
            percent:
              type: integer
            tier:
              type: string
              enum:
              - good
              - warn
              - bad
              description: Completion band (≥80 good, ≥40 warn, else bad — Execution::CoverageStatus).
            total:
              type: integer
            done:
              type: integer
            in_progress:
              type: integer
            not_started:
              type: integer
            missed:
              type: integer
            blocked:
              type: integer
              description: Obligations flagged blocked (an annotation on top of their
                status).
            unassigned:
              type: integer
            sites_reporting:
              type: integer
            active_campaigns:
              type: integer
              description: Every active campaign in the view
              withheld posts included.:
            scheduled_campaigns:
              type: integer
            people_withheld:
              type: object
              description: 'Knowledge Layer X3 (#4546) — campaign lens only: present
                when one or more active posts a works council covers hold fewer people
                than the company''s smallest report group in this view; their items
                are not in the status figures above. Every post it counts is also
                counted by `posts_left_out`, which is always present beside it: a
                client showing `posts_left_out` need not show this too (each withheld
                card says so itself).'
              properties:
                reason:
                  type: string
                  example: Fewer than 5 people — counted in the level above.
                campaigns:
                  type: integer
                  description: How many active posts the figures leave out.
            posts_left_out:
              type: object
              description: 'Finding #7955 — present in either lens (in the campaign
                lens since 2026-10-31; before, only in the nodes lens) while a works
                council is on and an active post reaches this view. The status figures
                above, and in the nodes lens every node card, then count no post (a
                location whose only active work is posts reads as one with no work);
                `active_campaigns` still counts them. A post''s store item is done
                only when everyone at the store has confirmed, so a small store''s
                count would say whether those few people had. Each post is its own
                card in the campaign lens, counted in people. `reason` is the sentence
                to show, worded for the lens served.'
              properties:
                reason:
                  type: string
                  description: 'Nodes lens: "Posts are not counted in these figures.
                    Each post is reported under By campaign." Campaign lens: "Posts
                    are not counted in these figures. Each post is reported on its
                    own card."'
                  example: Posts are not counted in these figures. Each post is reported
                    under By campaign.
                campaigns:
                  type: integer
                  description: How many active posts the status figures leave out.
        view:
          type: string
          enum:
          - nodes
          - campaign
          description: The lens actually served.
        default_view:
          type: string
          enum:
          - nodes
          - campaign
        tabs:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                enum:
                - nodes
                - campaign
              label:
                type: string
                description: e.g. "By district", "By campaign".
              active:
                type: boolean
        nodes:
          type: array
          description: The child-location cards (present only when view=nodes).
          items:
            "$ref": "#/components/schemas/CoverageNodeCard"
        campaigns:
          type: array
          description: The worst-first campaign cards (present only when view=campaign).
          items:
            "$ref": "#/components/schemas/CoverageCampaignCard"
        meta:
          type: object
          description: Pagination for the campaign lens (present only when view=campaign).
    CoverageNodeCard:
      type: object
      description: A region / district / store, with its subtree rolled up. Semantic
        fields only — the client owns the colour. While the header carries `posts_left_out`,
        no figure here counts a post.
      properties:
        location_id:
          type: integer
        name:
          type: string
        level_name:
          type: string
          nullable: true
        leaf:
          type: boolean
          description: True when descending stops here (link to the store's day instead).
        store_count:
          type: integer
        percent:
          type: integer
        tier:
          type: string
          enum:
          - good
          - warn
          - bad
        total:
          type: integer
        done:
          type: integer
        in_progress:
          type: integer
        not_started:
          type: integer
        missed:
          type: integer
        blocked:
          type: integer
        unassigned:
          type: integer
        inactive:
          type: boolean
          description: A deactivated store that still holds work — listed so the level
            adds up; show an Inactive badge.
    CoverageCampaignCard:
      type: object
      description: One campaign's slice of the caller's span. The client composes
        the meta line from these fields.
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
          description: The campaign's urgency band.
        work_type:
          type: string
          enum:
          - task
          - inspection
          - acknowledgement
          description: What KIND of work this campaign is.
        work_type_label:
          type: string
          description: The floor-facing label for `work_type` — "Task", "Inspection"
            or "Post" (an acknowledgement).
        recurring:
          type: boolean
        starts_on:
          type: string
          format: date
          nullable: true
        ends_on:
          type: string
          format: date
          nullable: true
        sites:
          type: integer
          description: Distinct locations the campaign touches in the span.
        percent:
          type: integer
          nullable: true
          description: '0–100, in the `unit` it names: for a post (`unit: people`)
            the share of its people in this view who confirmed it (`people.confirmed`
            ÷ `people.expected`), else done ÷ total — the figure the cards are ranked
            worst-first by, as the web Coverage list ranks them. Null while `people_withheld`
            is present.'
        tier:
          type: string
          nullable: true
          enum:
          - good
          - warn
          - bad
          description: The band of `percent`. Null while `people_withheld` is present.
        unit:
          type: string
          enum:
          - people
          - locations
          description: What `percent` counts — `people` for a post with work in this
            view, else `locations`. The status counts always count obligations. Additive.
        people:
          type: object
          nullable: true
          description: A post's "N of M people confirmed" pair in this view, the numbers
            `percent` is drawn from. Null for every other work type and while `people_withheld`
            is present. Additive.
          properties:
            expected:
              type: integer
            confirmed:
              type: integer
        total:
          type: integer
        done:
          type: integer
          nullable: true
          description: Null while `people_withheld` is present.
        in_progress:
          type: integer
          nullable: true
          description: Null while `people_withheld` is present.
        awaiting_review:
          type: integer
          nullable: true
          description: Obligations submitted and awaiting review — a SUBSET of in_progress,
            surfaced for the card's "N awaiting review" line. Null while `people_withheld`
            is present.
        not_started:
          type: integer
          nullable: true
          description: Null while `people_withheld` is present.
        missed:
          type: integer
          nullable: true
          description: Null while `people_withheld` is present.
        blocked:
          type: integer
        unassigned:
          type: integer
          nullable: true
          description: Null while `people_withheld` is present — open work with no
            holder is work those few have not finished.
        ready_to_close:
          type: boolean
          nullable: true
          description: Active and every obligation settled (done or missed). Null
            while `people_withheld` is present.
        people_withheld:
          type: object
          description: Knowledge Layer X3 (#4546) — present only on a post a works
            council covers whose audience in this view (the `node` drilled into, else
            the caller's span) holds fewer people than the company's smallest report
            group. Its status counts, unassigned, percent, tier, people and ready_to_close
            are null (a post's store is done only when everyone there confirmed),
            it is listed after every card that shows figures, by name, and `reason`
            is the sentence to show.
          properties:
            reason:
              type: string
              example: Fewer than 5 people — counted in the level above.
    CoverageNudgeResult:
      type: object
      description: The outcome of a rollup nudge, counted in managers.
      properties:
        nudged:
          type: array
          items:
            type: string
          description: The manager names who received a nudge.
        nudged_count:
          type: integer
        skipped:
          type: array
          items:
            type: string
          description: Managers skipped — already nudged today, or no reachable manager.
        yours:
          type: array
          items:
            type: string
          description: Areas whose nearest manager is the caller — nobody is nudged
            about them, and `message` says so.
        message:
          type: string
          example: Nudged 2 managers. 1 skipped — already nudged today, or no reachable
            manager. You manage North District yourself, so nobody was nudged about
            it.
      required:
      - nudged
      - nudged_count
      - skipped
      - yours
      - message
    CoverageNudgeError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - nothing_to_nudge
              - nothing_behind
              - not_found
            message:
              type: string
    CoverageRemindResult:
      type: object
      description: The answer to a campaign-wide Remind, counted in people before
        anything is sent; the reminders themselves go out in the background.
      properties:
        reminded:
          type: integer
          nullable: true
          description: People the queued pass is reminding (within the 200-reminder
            cap). Null while `people_withheld` is present.
        skipped:
          type: integer
          nullable: true
          description: People reminded in the last 24 hours — every one of their obligations
            was — so they are not reminded again. Null while `people_withheld` is
            present.
        remaining:
          type: integer
          nullable: true
          description: People past the 200-reminder cap this pass will not reach —
            run Remind again to reach them. Null while `people_withheld` is present.
        queued:
          type: boolean
          description: Whether a pass was queued — true when it is reminding at least
            one person; false when nobody is outstanding or everyone outstanding was
            reminded in the last 24 hours.
        more_outstanding:
          type: boolean
          description: Present only with `people_withheld` — whether people are past
            the cap (run Remind again).
        people_withheld:
          type: object
          description: |
            Present only when the caller's whole span holds fewer people than
            the company's smallest report group on a post a works council
            covers: no count is given, and `message` says what the pass is
            doing without one —
            "Reminding the people who still owe it.",
            "Reminding the people who still owe it, except some reminded in the last 24 hours.",
            either followed by " Some are still outstanding — run Remind again to reach them."
            when people are past the cap, or
            "Nobody new was reminded — the people who still owe it were reminded in the last 24 hours."
          properties:
            reason:
              type: string
              example: Fewer than 5 people — counted in the level above.
        message:
          type: string
          description: |
            A ready-to-show sentence, identical to the web Coverage notice:
            "Reminding 3 people." plus " 1 other person reminded in the last
            24 hours is skipped." when people were skipped and " 2 more are
            still outstanding — run Remind again to reach them." when people
            are past the cap; "Everyone still outstanding was reminded in the
            last 24 hours, so nobody was reminded again." when nobody new is
            reminded; "Nobody has work outstanding on this campaign." when nobody is
            outstanding.
          example: Reminding 3 people. 1 other person reminded in the last 24 hours
            is skipped.
      required:
      - reminded
      - skipped
      - remaining
      - queued
      - message
    DaySheetCounts:
      type: object
      description: Exact size of all eight filters (0 included). The six status buckets
        are a partition; `everything` is their union minus `completed`; `escalated`
        overlaps `blocked`.
      properties:
        everything:
          type: integer
          example: 12
        escalated:
          type: integer
          example: 1
        blocked:
          type: integer
          example: 3
        unassigned:
          type: integer
          example: 4
        sent_back:
          type: integer
          example: 1
        in_review:
          type: integer
          example: 2
        newly_assigned:
          type: integer
          example: 2
        completed:
          type: integer
          example: 9
    DaySheetListItem:
      type: object
      description: One row of the Day Sheet — a piece of work landing on a store today.
      properties:
        id:
          type: integer
          example: 90210
        campaign_id:
          type: integer
          example: 512
        campaign:
          type: string
          nullable: true
          example: Pharmacy Cold Chain Audit
        category:
          type: object
          nullable: true
          description: The campaign's programme (category), or null.
          properties:
            id:
              type: integer
              example: 42
            name:
              type: string
              example: Compliance
        title:
          type: string
          nullable: true
          example: Fridge 2 — general stock
        work_type:
          type: string
          nullable: true
          description: task / inspection / acknowledgement.
          example: inspection
        work_type_label:
          type: string
          nullable: true
          example: Inspection
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          example: 3369
        state:
          type: string
          description: The derived Day Sheet bucket this row belongs to.
          enum:
          - blocked
          - sent_back
          - unassigned
          - in_review
          - newly_assigned
          - completed
          - other
          example: blocked
        status:
          type: string
          description: The raw lifecycle status behind the derived state.
          enum:
          - open
          - claimed
          - in_progress
          - submitted
          - done
          - reopened
          - missed
          example: in_progress
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
          example: critical
        overdue:
          type: boolean
          example: true
        escalated:
          type: boolean
          description: This blocked row's blocker group has an open escalation.
          example: false
        due_at:
          type: string
          format: date-time
          nullable: true
        handoff_count:
          type: integer
          description: How many times this work has been handed off.
          example: 1
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: false
        requires_review:
          type: boolean
          nullable: true
          example: true
        assignee:
          type: object
          nullable: true
          description: The current holder, or null for unassigned (pool) rows.
          properties:
            id:
              type: integer
              example: 771
            name:
              type: string
              example: Anthony Rivera
        blocked_reason:
          type: string
          nullable: true
          description: Present when blocked — the machine reason code.
          example: equipment_down
        blocked_reason_label:
          type: string
          nullable: true
          description: Present when blocked — the human label for the reason.
          example: Equipment is down
        blocked_note:
          type: string
          nullable: true
          description: Present when blocked — the worker's free-text note.
          example: Fridge 2 alarm is sounding, panel reads ERR.
        blocked_at:
          type: string
          format: date-time
          nullable: true
        review_note:
          type: string
          nullable: true
          description: Present while the work is open again after a send-back (reopened,
            or handed on before it was redone) — the reviewer's send-back note.
          example: The left bay is still showing last season's card.
        reopened_at:
          type: string
          format: date-time
          nullable: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
          description: Present when in_review.
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: Present when completed.
    DaySheetListMeta:
      type: object
      description: Pagination envelope for the SELECTED filter, plus the unrecognised-filter
        and unknown-category disclosures. Note the boolean keys are `has_next_page`
        / `has_prev_page`.
      properties:
        total_count:
          type: integer
          example: 12
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present only when an unrecognised `filter` was defaulted to
            `everything`.
          example: true
        filter_note:
          type: string
          description: Human-readable note when `filter_ignored` is set.
        category_filter_ignored:
          type: boolean
          description: Present only when an unknown `category_id` was ignored.
          example: true
        category_filter_note:
          type: string
          description: Human-readable note when `category_filter_ignored` is set.
    DaySheetCategory:
      type: object
      description: One campaign category the sheet can be filtered to.
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: Planogram
        color_token:
          type: string
          nullable: true
          description: Bootstrap subtle-badge token for the category chip (primary/secondary/success/danger/warning/info/dark),
            or null.
          example: info
        active:
          type: boolean
          description: False for a retired category — still listed so a filtered historical
            sheet resolves.
          example: true
    DaySheetStore:
      type: object
      description: One store the sheet can be opened for (id + display name only).
      properties:
        id:
          type: integer
          example: 3369
        name:
          type: string
          example: Store 412 — Riverside
    DaySheetLocationsMeta:
      type: object
      description: Disclosure for the store list — count, cap state, and empty-state
        signal.
      properties:
        total:
          type: integer
          description: True store count in the caller's span (>= shown).
          example: 8
        shown:
          type: integer
          description: How many stores are returned (capped at 2,000).
          example: 8
        has_more:
          type: boolean
          description: True when the list was cut at the picker cap.
          example: false
        deactivated_only:
          type: boolean
          description: True when the span holds physical stores but every one is deactivated
            — lets a client distinguish "switched off" from "none imported".
          example: false
    ShiftBriefWindow:
      type: object
      description: The look-back window actually applied, plus the pickable options
        and snap disclosure.
      properties:
        since_hours:
          type: integer
          description: The window applied (one of the offered options).
          enum:
          - 4
          - 8
          - 12
          - 24
          - 48
          - 72
          example: 12
        since:
          type: string
          format: date-time
          description: The start of the window (now − since_hours).
        options:
          type: array
          description: The pickable windows, for the selector.
          items:
            type: integer
          example:
          - 4
          - 8
          - 12
          - 24
          - 48
          - 72
        since_hours_ignored:
          type: boolean
          description: Present only when an unrecognised `since_hours` was defaulted
            to 12.
          example: true
        since_hours_note:
          type: string
          description: Human-readable note when `since_hours_ignored` is set.
    ShiftBriefBucketSummary:
      type: object
      properties:
        count:
          type: integer
          example: 3
        capped:
          type: boolean
          description: True when the list is at bucket_limit — there may be more.
          example: false
        total:
          type: integer
          description: The true total, present only for the counted buckets (completed
            / escalated).
          example: 43
    ShiftBriefRow:
      type: object
      description: One brief row, titled by campaign name (ShiftBrief does not resolve
        work titles).
      properties:
        id:
          type: integer
          example: 1187
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          example: Pharmacy Cold Chain Audit
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 8
        status:
          type: string
          enum:
          - open
          - claimed
          - in_progress
          - submitted
          - reopened
          - done
          - missed
          example: claimed
        overdue:
          type: boolean
          example: true
        due_at:
          type: string
          format: date-time
          nullable: true
        assignee:
          type: object
          nullable: true
          description: The current holder — null for claim-pool rows.
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Anthony Rivera
    ShiftBriefBlockedRow:
      allOf:
      - "$ref": "#/components/schemas/ShiftBriefRow"
      - type: object
        description: A blocked row also carries the structured blocker reason.
        properties:
          blocked_reason:
            type: string
            nullable: true
            enum:
            - no_stock
            - missing_fixture_or_supplies
            - equipment_down
            - not_enough_time
            - unclear_instructions
            - other
          blocked_reason_label:
            type: string
            nullable: true
            example: Equipment down
          blocked_note:
            type: string
            nullable: true
            example: Fridge panel unresponsive
    ShiftBriefCompletedRow:
      allOf:
      - "$ref": "#/components/schemas/ShiftBriefRow"
      - type: object
        description: A completed row also carries WHEN it was finished.
        properties:
          completed_at:
            type: string
            format: date-time
            nullable: true
    HeldCampaignsResult:
      type: object
      properties:
        campaigns:
          type: array
          items:
            "$ref": "#/components/schemas/HeldCampaignCard"
        total_count:
          type: integer
          description: How many held campaigns are in this response.
        capped:
          type: boolean
          description: True when the count hit the server cap (more may exist).
      required:
      - campaigns
      - total_count
      - capped
    HeldCampaignCard:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          description: Always "scheduled" for a held campaign.
        priority:
          type: string
        work_type:
          type: string
        work_type_label:
          type: string
        would_launch_on:
          type: string
          format: date
          nullable: true
          description: The date it is scheduled to launch if released.
        submitted_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        submitted_at:
          type: string
          format: date-time
          description: When it entered the release gate.
      required:
      - id
      - name
      - status
      - priority
      - would_launch_on
    FrontlineItemViewer:
      type: object
      description: The caller's lens on this obligation and the actions they may take.
      properties:
        is_holder:
          type: boolean
          description: The caller currently holds this obligation.
          example: false
        is_reviewer:
          type: boolean
          description: The caller may review work at this location.
          example: true
        can:
          type: object
          description: Which of the six item actions to show for this caller — mirrors
            the web display helpers. The write endpoints re-check at the transition.
          properties:
            claim:
              type: boolean
              example: false
            complete:
              type: boolean
              example: false
            release:
              type: boolean
              example: false
            block:
              type: boolean
              example: false
            challenge:
              type: boolean
              example: false
            review:
              type: boolean
              example: true
    FrontlineItemDetail:
      type: object
      description: One obligation in full. Lens-gated sections may be absent.
      properties:
        id:
          type: integer
          example: 18
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          example: Pharmacy Cold Chain Audit
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Compliance
        title:
          type: string
          example: Pharmacy Cold Chain Audit
        work_type:
          type: string
          nullable: true
          example: task
        work_type_label:
          type: string
          nullable: true
          example: Task
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 12
        status:
          type: string
          example: in_progress
        withdrawn:
          type: boolean
          example: false
          description: True when this work was withdrawn before anyone could start
            it — its shift was cancelled or reassigned, or its campaign version was
            replaced. Its status stays `missed`, but it counts as neither done nor
            missed in any coverage figure.
        priority:
          type: string
          example: critical
        overdue:
          type: boolean
          example: true
        due_at:
          type: string
          format: date-time
          nullable: true
        estimated_minutes:
          type: integer
          nullable: true
          example: 15
        handoff_count:
          type: integer
          example: 0
        inspection_id:
          type: integer
          nullable: true
          example: 4821
          description: The Inspection this item minted when the campaign's kind of
            work is inspection; null otherwise. Open it to do the work — Mark done
            is refused for inspection work.
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: true
        requires_review:
          type: boolean
          nullable: true
          example: true
        blocked:
          type: boolean
          example: false
        blocked_reason:
          type: string
          nullable: true
        blocked_reason_label:
          type: string
          nullable: true
        blocked_note:
          type: string
          nullable: true
        blocked_at:
          type: string
          format: date-time
          nullable: true
        review_note:
          type: string
          nullable: true
          description: 'The reviewer''s send-back note — present while the work is
            open again after a send-back: reopened, or handed on or released before
            it was redone, so the next person still sees what to fix.'
        reopened_at:
          type: string
          format: date-time
          nullable: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        instruction:
          type: string
          nullable: true
          description: What HQ asked for, in full (the work's description, else the
            campaign's).
        documents:
          type: array
          description: The campaign's reference documents (expiring download URLs).
          items:
            type: object
            properties:
              id:
                type: integer
                example: 91
              filename:
                type: string
                example: cold-chain-sop-v4.pdf
              byte_size:
                type: integer
                example: 2202009
              content_type:
                type: string
                example: application/pdf
              url:
                type: string
                example: https://.../rails/active_storage/...
        holder:
          type: object
          nullable: true
          description: The current holder, or null when unclaimed.
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Priya Shah
        accountable:
          type: object
          nullable: true
          description: The accountable manager — present only when unclaimed.
          properties:
            id:
              type: integer
              example: 3
            name:
              type: string
              example: Dana Okafor
        training:
          type: object
          description: The completion training gate and any manager override.
          properties:
            requires_training:
              type: boolean
              example: false
            courses:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    example: 4
                  title:
                    type: string
                    example: Cold Chain Handling
            override:
              type: object
              nullable: true
              properties:
                granted_by:
                  type: string
                  nullable: true
                  example: Dana Okafor
                reason:
                  type: string
                  nullable: true
                granted_at:
                  type: string
                  nullable: true
        challenge:
          type: object
          nullable: true
          description: The assignment challenge in any state ("this shouldn't be mine"),
            or null.
          properties:
            status:
              type: string
              example: open
            category:
              type: string
              example: not_my_area
            category_label:
              type: string
              example: Not my area
            note:
              type: string
              nullable: true
            raised_at:
              type: string
              nullable: true
            outcome:
              type: string
              nullable: true
            reason:
              type: string
              nullable: true
            resolved_at:
              type: string
              nullable: true
            resolved_by_name:
              type: string
              nullable: true
              description: 'Who answered the challenge, by name ("Deleted user" once
                their account is gone) — render the web''s "<resolved_by_name> kept
                this with you: <reason>", never "Your manager": the answer can come
                from a district manager or an admin. Null while open.'
        checklist:
          type: object
          description: 'The ordered steps the holder works through — present ONLY
            when the minted work is a checklist task carrying steps (absent for an
            inspection, a simple task, or an unclaimed obligation with no work yet).
            Lens-independent: both holder and reviewer see it. `total`/`completed`/`percentage`
            come from the task''s own counters.'
          properties:
            total:
              type: integer
              example: 5
            completed:
              type: integer
              example: 2
            percentage:
              type: integer
              example: 40
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                    example: 310
                  title:
                    type: string
                    example: Read fridge B from the front panel
                  description:
                    type: string
                    nullable: true
                  sort_order:
                    type: integer
                    example: 0
                  completed:
                    type: boolean
                    example: true
                  completed_at:
                    type: string
                    format: date-time
                    nullable: true
                  completed_by:
                    type: string
                    nullable: true
                    description: Who ticked this step (null while pending).
                    example: Priya Shah
                  requires_photo:
                    type: boolean
                    example: false
                  requires_notes:
                    type: boolean
                    example: false
        assignment_history:
          type: object
          description: REVIEWER LENS ONLY. Who held it and why it moved — newest first,
            capped at 50, with the true total.
          properties:
            total:
              type: integer
              example: 3
            shown:
              type: integer
              example: 3
            has_more:
              type: boolean
              example: false
            events:
              type: array
              items:
                type: object
                properties:
                  event:
                    type: string
                    example: reassign
                  sentence:
                    type: string
                    description: The rendered one-line history sentence.
                    example: Reassigned to Priya Shah by Dana Okafor
                  note:
                    type: string
                    nullable: true
                  occurred_at:
                    type: string
                    format: date-time
                    nullable: true
        review_proof:
          description: REVIEWER LENS ONLY, and only when `status` is `submitted` —
            the inline proof the review queue renders (photos, signature, note, AI
            verdict). Same shape as the review-queue row's `proof`.
          type: object
        capture:
          type: object
          description: HOLDER / ROSTER LENS ONLY — what's already on the record.
          properties:
            captured_photos:
              type: integer
              description: Photos that count as this attempt's proof. Photos from
                before a send-back are history, not proof, and are counted in `earlier_attempt_photos`
                instead.
              example: 2
            earlier_attempt_photos:
              type: integer
              example: 1
            signature:
              type: object
              nullable: true
              properties:
                signed:
                  type: boolean
                  example: true
                signed_by:
                  type: string
                  nullable: true
                  example: Priya Shah
                signed_at:
                  type: string
                  nullable: true
            notes_supported:
              type: boolean
              example: true
            notes:
              type: array
              items:
                type: object
                properties:
                  author:
                    type: string
                    example: Priya Shah
                  body:
                    type: string
                    example: Stock landed on the 6am truck.
                  created_at:
                    type: string
                    format: date-time
                    nullable: true
        assignment_explanation:
          type: object
          nullable: true
          description: HOLDER LENS ONLY — "why this is mine", from the basis recorded
            when the person was bound.
          properties:
            sentence:
              type: string
              example: You're the nearest active manager for Fourth Street.
            checks:
              type: string
              nullable: true
            rule:
              type: string
              nullable: true
              example: location_manager
            recorded:
              type: boolean
              example: true
    ItemNudgeResult:
      type: object
      description: The outcome of a per-item nudge.
      properties:
        nudged:
          type: boolean
          description: True when a fresh reminder was delivered; false when suppressed.
          example: true
        throttled:
          type: boolean
          description: True when nothing new was sent — already reminded in the last
            24 hours, or the recipient couldn't be reached. Always the inverse of
            `nudged`.
          example: false
        recipient:
          type: object
          description: The person the reminder addressed (the holder, else the accountable
            manager).
          properties:
            id:
              type: integer
              example: 4821
            name:
              type: string
              example: Dana Ruiz
        message:
          type: string
          description: A ready-to-show sentence describing the outcome.
          example: Reminder sent to Dana Ruiz.
        item:
          type: object
          description: The obligation the nudge was about, in the same lean shape
            the item write endpoints return.
      required:
      - nudged
      - throttled
      - recipient
      - message
      - item
    ItemNudgeError:
      type: object
      description: The standard error envelope for a refused nudge.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`no_recipient` when there is nobody to nudge."
              example: no_recipient
            message:
              type: string
              example: Nobody holds this work and no manager covers its location,
                so there's nobody to nudge.
    FrontlineExecutionLocationsResult:
      type: object
      description: The paginated store list plus the standard pagination meta.
      properties:
        locations:
          type: array
          description: The active physical sites, ordered by name.
          items:
            "$ref": "#/components/schemas/FrontlineExecutionLocation"
        meta:
          type: object
          description: Standard pagination envelope.
          properties:
            total_count:
              type: integer
              example: 128
            total_pages:
              type: integer
              example: 6
            current_page:
              type: integer
              example: 1
            per_page:
              type: integer
              example: 25
            has_next_page:
              type: boolean
              example: true
            has_prev_page:
              type: boolean
              example: false
      required:
      - locations
      - meta
    FrontlineExecutionLocation:
      type: object
      description: One selectable store for the "Which locations" picker.
      properties:
        id:
          type: integer
          example: 55
        name:
          type: string
          example: Fourth Street
      required:
      - id
      - name
    MyDayListItem:
      type: object
      description: One My Day obligation (Execution::CampaignItem), with the fields
        the My Day row shows, including the "I can't do this" flag and the note the
        worker left their manager with it. A lean, list-optimised shape — the fuller
        single-item shape (assignment explanation, challenge) is returned by the item
        action endpoints.
      properties:
        id:
          type: integer
          example: 1187
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          description: The campaign's name — one campaign lands the same work at many
            sites.
          example: Pharmacy Cold Chain Audit
        title:
          type: string
          nullable: true
          description: The work's own title, falling back to the campaign name.
          example: Pharmacy Cold Chain Audit
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 8
        status:
          type: string
          description: Lifecycle status of the obligation.
          enum:
          - open
          - claimed
          - in_progress
          - submitted
          - reopened
          - done
          - missed
          example: claimed
        priority:
          type: string
          description: The campaign's HQ priority (absent reads as normal).
          enum:
          - normal
          - important
          - critical
          example: critical
        overdue:
          type: boolean
          example: true
        due_at:
          type: string
          format: date-time
          nullable: true
        rank_reason:
          type: string
          description: The same per-row "why it's ranked here" sentence the web My
            Day shows (overdue → critical → due today → due later → important → none).
            The anti-overload cap variant ("more than your N critical") is not reproduced
            on this list — a capped-over critical still reads "Marked critical by
            HQ."
          example: Overdue since 3:14 PM.
        estimated_minutes:
          type: integer
          nullable: true
          description: The campaign's estimated duration.
          example: 15
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: true
        requires_review:
          type: boolean
          nullable: true
          example: true
        handoff_count:
          type: integer
          example: 0
        blocked:
          type: boolean
          description: Whether the worker has flagged this as "I can't do this".
          example: false
        blocked_reason:
          type: string
          nullable: true
          description: What is blocking it — the reason the worker picked when flagging.
            Null when not flagged.
          enum:
          - no_stock
          - missing_fixture_or_supplies
          - equipment_down
          - not_enough_time
          - unclear_instructions
          - other
        blocked_reason_label:
          type: string
          nullable: true
          description: Human-readable label for `blocked_reason`. Null when not flagged.
          example: Equipment is down
        blocked_note:
          type: string
          nullable: true
          maxLength: 500
          description: 'The note the worker left for their manager when flagging it
            ("Anything your manager should know"). Optional when flagging, so it can
            be null on a flagged row; always null on an unflagged one. A client renders
            the web''s "Flagged: <label> — <note>" line from `blocked_reason_label`
            + `blocked_note`.'
          example: Walk-in cooler alarm going off, can't restock until it's fixed
        blocked_at:
          type: string
          format: date-time
          nullable: true
          description: When it was flagged. Null when not flagged.
        claimable:
          type: boolean
          description: True only for claim-pool rows (nobody holds them yet).
          example: false
        assignee:
          type: object
          nullable: true
          description: The current holder — null for claim-pool rows.
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Anthony Rivera
    MyDayListMeta:
      type: object
      description: Pagination envelope for the SELECTED filter, plus the unrecognised-filter
        disclosure. Note the boolean keys are `has_next_page` / `has_prev_page`.
      properties:
        total_count:
          type: integer
          example: 7
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present only when an unrecognised `filter` was defaulted to
            `assigned`.
          example: true
        filter_note:
          type: string
          description: Human-readable note when `filter_ignored` is set.
    UnifiedDayRow:
      type: object
      description: One item of the worker's day, projected from its owning app. Acted
        on in that app — this surface only ranks and links.
      properties:
        source:
          type: string
          description: Which app this row came from.
          enum:
          - frontline
          - inspections
          - must_reads
          - tasks
          - training
          - shift_offers
          - approvals
          - onboarding
          - signatures
          example: frontline
        key:
          type: string
          description: Stable cross-source id, for de-dup across pages.
          example: frontline-1187
        title:
          type: string
          example: Close-out safety walk
        subtitle:
          type: string
          nullable: true
          description: Location or lane context.
          example: Store 412
        priority:
          type: string
          enum:
          - critical
          - important
          - normal
          example: normal
        overdue:
          type: boolean
          example: false
        due_at:
          type: string
          format: date-time
          nullable: true
        url:
          type: string
          nullable: true
          description: Where to act on the row, in the app that owns it. Null when
            that app resolved no route (render no link rather than a broken one).
          example: "/apps/frontline-execution/my-day"
        mobile_url:
          type: string
          nullable: true
          description: The row's phone page, where it is not simply the phone form
            of `url` (an onboarding form). Null otherwise.
        action_label:
          type: string
          description: The verb for the row's action.
          example: Open
        rank_reason:
          type: string
          nullable: true
          description: Why the row is ranked where it is.
          example: Due 5:00 PM.
        blocked:
          type: boolean
          description: Whether the worker has flagged this as "I can't do this". Only
            a `frontline` row can be flagged; every other source is always false.
          example: true
        blocked_reason:
          type: string
          nullable: true
          description: What is blocking it — the reason the worker picked when flagging.
            Null when not flagged.
          enum:
          - no_stock
          - missing_fixture_or_supplies
          - equipment_down
          - not_enough_time
          - unclear_instructions
          - other
          example: missing_fixture_or_supplies
        blocked_reason_label:
          type: string
          nullable: true
          description: Human-readable label for `blocked_reason`. Null when not flagged.
          example: Missing fixture or supplies
        blocked_note:
          type: string
          nullable: true
          maxLength: 500
          description: The note the worker left for their manager when flagging it
            ("Anything your manager should know"). Optional when flagging, so it can
            be null on a flagged row; always null on an unflagged one.
          example: Promo signage never arrived in the delivery
        blocked_at:
          type: string
          format: date-time
          nullable: true
          description: When it was flagged. Null when not flagged.
    UnifiedDayListMeta:
      type: object
      description: Pagination envelope for the SELECTED filter, plus the unrecognised-filter
        disclosure. Note the boolean keys are `has_next_page` / `has_prev_page`.
      properties:
        total_count:
          type: integer
          example: 12
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present only when an unrecognised `filter` was defaulted to
            `all`.
          example: true
        filter_note:
          type: string
          description: Human-readable note when `filter_ignored` is set.
    FrontlineNoteItem:
      type: object
      description: The obligation after the note — the same shape the item-action
        endpoints return.
      properties:
        id:
          type: integer
          example: 18
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Fourth Street
        location_id:
          type: integer
          nullable: true
          example: 12
        status:
          type: string
          example: claimed
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          nullable: true
          example: true
        requires_signature:
          type: boolean
          nullable: true
          example: false
        blocked:
          type: boolean
          example: false
    FrontlineRecordedNote:
      type: object
      description: The note that was just recorded, ready to append to the thread.
      properties:
        author:
          type: string
          description: The note's author, as it appears in the trail (full name, falling
            back to name).
          example: Dana Holder
        body:
          type: string
          description: The note text (bounded to 500 characters).
          example: Endcap reset done, but two SKUs were out of stock — flagged to
            the DM.
        created_at:
          type: string
          format: date-time
          nullable: true
          description: When the trail activity was recorded (null only if activity
            logging degraded).
    FrontlineNoteError:
      type: object
      description: The standard error envelope for a refused note.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`invalid_note`, `acknowledgement`, `unsupported_work`
                or `no_work`."
              example: invalid_note
            message:
              type: string
              example: Write the note first.
    ShiftPassdown:
      type: object
      properties:
        id:
          type: integer
          example: 42
        status:
          type: string
          enum:
          - complete
          - machine_closed
          description: |
            `complete` — a human filled it in.
            `machine_closed` — the shift ended without anyone being asked.
        machine_closed:
          type: boolean
          example: false
        location_id:
          type: integer
          example: 1118
        author:
          nullable: true
          type: object
          description: Null on a machine_closed record — that is the point of it.
          properties:
            id:
              type: integer
            name:
              type: string
        shift_ended_at:
          type: string
          format: date-time
        notes:
          type: string
          nullable: true
        structured:
          type: object
          additionalProperties:
            type: string
          example:
            headcount: '14'
            open_items: '2'
        captured_via:
          type: string
          enum:
          - day_sheet
          - wrap_up
          - kiosk
          - mobile
          - api
          description: Which surface collected it.
        acknowledged:
          type: boolean
        acknowledgeable:
          type: boolean
          description: |
            Whether THIS caller may acknowledge it — false once acknowledged, for its
            author, and for anyone who worked a shift at this store ending within 30
            minutes of it.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        acknowledged_by:
          nullable: true
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
    FrontlineQuestion:
      type: object
      description: One Q&A comment — a question or a reply.
      properties:
        id:
          type: integer
          example: 512
        body:
          type: string
          description: Plain-text body, with @mentions rendered as @Name.
          example: Do the small-format stores skip the endcap?
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 88
            name:
              type: string
              example: Dana Ruiz
            photo_url:
              type: string
              description: The author's avatar URL — their uploaded profile photo
                when they have one, otherwise a generated initials avatar. Always
                present and absolute, so a native thread can show a face beside each
                post.
              example: https://example.mangoapps.com/rails/active_storage/representations/redirect/eyJ.../avatar.jpg
        parent_id:
          type: integer
          nullable: true
          description: The question this replies to; null for a top-level question.
          example:
        is_campaign_owner:
          type: boolean
          description: True when the author is the person who owns (created) the campaign
            — the web thread's "Campaign owner" badge, which marks an answer as authoritative.
          example: false
        created_at:
          type: string
          format: date-time
        edited:
          type: boolean
          description: True when the comment was edited after it was posted.
          example: false
        replies:
          type: array
          description: Present on a top-level question in the GET thread; the replies
            to it, oldest first.
          items:
            "$ref": "#/components/schemas/FrontlineQuestion"
    FrontlineQuestionThread:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
              example: 42
            name:
              type: string
              example: Seasonal Endcap Reset
            status:
              type: string
              example: active
            accepting_questions:
              type: boolean
              description: Whether the campaign still takes NEW questions (false once
                closed).
              example: true
        questions:
          type: array
          items:
            "$ref": "#/components/schemas/FrontlineQuestion"
        question_count:
          type: integer
          description: The true total of top-level questions.
          example: 3
        meta:
          type: object
          properties:
            showing:
              type: integer
              example: 3
            total:
              type: integer
              example: 3
            truncated:
              type: boolean
              description: True when there are more questions than the returned slice.
              example: false
    FrontlineQuestionError:
      type: object
      description: The standard error envelope for a refused question.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`forbidden`, `invalid_parent`, `blank` or `too_long`."
              example: blank
            message:
              type: string
              example: Write your question first.
    ItemReassignResult:
      type: object
      description: The outcome of a reassign — the reloaded obligation, now with the
        new holder.
      properties:
        item:
          type: object
          description: The obligation, in the same lean shape the other item write
            endpoints return (id, campaign_id, title, location, status, assignee,
            etc.).
      required:
      - item
    ItemReassignError:
      type: object
      description: The standard error envelope for a refused reassign.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: One of `invalid_request`, `not_at_location`, `reason_required`,
                `already_assigned`, `already_done`, `in_review`, or a leave refusal.
              example: not_at_location
            message:
              type: string
              example: Dana Ruiz isn't assigned to Fourth Street.
    ReassignCandidatesResult:
      type: object
      description: The ranked store roster plus the item context and pagination meta.
      properties:
        item:
          type: object
          description: The obligation the roster is for.
          properties:
            id:
              type: integer
              example: 90123
            location_id:
              type: integer
              example: 55
            location:
              type: string
              example: Fourth Street
            current_assignee_id:
              type: integer
              nullable: true
              description: The present holder (null for an unclaimed pool item).
              example: 4821
        candidates:
          type: array
          description: The people the work can be handed to, ranked most-available
            first.
          items:
            "$ref": "#/components/schemas/ReassignCandidate"
        meta:
          type: object
          description: 'Standard pagination envelope. When the base roster hit the
            100-person cap, also carries `roster_capped: true` and a `roster_note`.'
          properties:
            total_count:
              type: integer
              example: 12
            total_pages:
              type: integer
              example: 1
            current_page:
              type: integer
              example: 1
            per_page:
              type: integer
              example: 25
            has_next_page:
              type: boolean
              example: false
            has_prev_page:
              type: boolean
              example: false
            roster_capped:
              type: boolean
              description: Present and true only when the store has more people than
                the cap.
              example: true
            roster_note:
              type: string
              description: Guidance to narrow the list with `q` when the roster was
                capped.
      required:
      - item
      - candidates
      - meta
    ReassignCandidate:
      type: object
      description: One person on the reassign roster, with the availability signals
        the picker sorts by.
      properties:
        id:
          type: integer
          example: 4830
        name:
          type: string
          example: Priya Sharma
        photo_url:
          type: string
          description: The person's avatar URL — their uploaded profile photo when
            they have one, otherwise a generated initials avatar. Always present and
            absolute, so a native picker can show a face beside the name.
          example: https://example.mangoapps.com/rails/active_storage/representations/redirect/eyJ.../avatar.jpg
        on_shift:
          type: boolean
          description: Whether the person is rostered at this store today.
          example: true
        open_load:
          type: integer
          description: The person's open workload (tenant-wide, due by end of day).
          example: 3
        on_leave:
          type: boolean
          description: Whether the person is on approved leave today. The reassign
            refuses them, so the picker labels it before the click.
          example: false
        is_current_assignee:
          type: boolean
          description: Whether this is the obligation's present holder.
          example: false
      required:
      - id
      - name
      - photo_url
      - on_shift
      - open_load
      - on_leave
      - is_current_assignee
    ReleasedItem:
      type: object
      description: The item after release — the same shape the item-action endpoints
        return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`open` after a successful release."
          example: open
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
          description: Unchanged by release — the obligation still falls due when
            it did.
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        claimable:
          type: boolean
          example: false
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        blocked:
          type: boolean
          example: false
        assignment_explanation:
          type: object
          nullable: true
          description: Why the (former) holder had this work; null once it is back
            in the pool.
    ReleaseError:
      type: object
      description: The standard error envelope for a refused release.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`already_done`, `in_review` or `not_assigned`."
              example: in_review
            message:
              type: string
              example: This work is waiting for review and can't be released.
    ReleaseReview:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
            priority:
              type: string
            work_type:
              type: string
            work_type_label:
              type: string
        held:
          type: boolean
          description: True when scheduled AND under publication review.
        would_launch_on:
          type: string
          format: date
          nullable: true
        sites:
          type: integer
          description: Distinct locations the campaign would target.
        obligations:
          type: integer
          description: Projected obligations — one per resolved location.
        assignment_strategy:
          type: string
          enum:
          - location_manager
          - single_assignee
          - distributed
          - claim_pool
        assignment_strategy_label:
          type: string
        proof:
          type: object
          properties:
            photo:
              type: boolean
            signature:
              type: boolean
            review:
              type: boolean
        window:
          type: object
          properties:
            starts_on:
              type: string
              format: date
              nullable: true
            ends_on:
              type: string
              format: date
              nullable: true
            eligible_from:
              type: string
              nullable: true
              description: Wall-clock start, e.g. "06:00".
            eligible_to:
              type: string
              nullable: true
        languages:
          type: object
          properties:
            list:
              type: array
              items:
                type: string
              description: Language codes the work would ship in ("en" plus each translation).
            translations:
              type: array
              items:
                type: object
                properties:
                  code:
                    type: string
                  verified:
                    type: boolean
            translations_verified:
              type: boolean
              description: True when every translation is verified and current.
        same_day:
          type: object
          properties:
            campaigns:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  name:
                    type: string
                  status:
                    type: string
                  obligations:
                    type: integer
            total_obligations:
              type: integer
              description: This campaign's projection plus every same-day sibling's
                load.
            capped:
              type: boolean
              description: True when more same-day siblings exist than the response
                lists.
        approval:
          type: object
          properties:
            under_review:
              type: boolean
            status:
              type: string
              nullable: true
              description: The open request's status (pending/escalated).
            submitted_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
            submitted_at:
              type: string
              format: date-time
              nullable: true
            can_decide:
              type: boolean
              description: Whether THIS caller may release/hold it (engine can_approve?).
      required:
      - campaign
      - held
      - sites
      - obligations
      - proof
      - window
      - languages
      - same_day
      - approval
    PublicationDecisionResult:
      type: object
      properties:
        campaign:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            status:
              type: string
              description: The post-decision status (a hold returns a scheduled campaign
                to "draft").
        approval:
          type: object
          properties:
            status:
              type: string
              description: The request's terminal status (approved/rejected).
        released:
          type: boolean
          description: True for a release
          false for a hold.:
        message:
          type: string
          description: A ready-to-show sentence.
      required:
      - campaign
      - approval
      - released
      - message
    RequestDecisionError:
      type: object
      description: The standard error envelope for a refused intake-request decision.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`reason_required`, `already_decided`, `engine_owned`,
                `invalid`, or (withdraw only) `forbidden`."
              example: reason_required
            message:
              type: string
              example: Say why — it's the only thing the person who asked for this
                will see.
    FrontlineRequestViewer:
      type: object
      description: The caller's lens on this intake request and the actions they may
        take.
      properties:
        is_requester:
          type: boolean
          description: The caller submitted this request.
          example: false
        is_gatekeeper:
          type: boolean
          description: The caller is in the campaign-author tier that works the queue.
          example: true
        can_approve:
          type: boolean
          description: Show the Approve control — the caller is a gatekeeper, the
            request is still pending, and no configured approval workflow owns the
            decision.
          example: true
        can_decline:
          type: boolean
          description: Show the Decline control — same condition as can_approve.
          example: true
        can_withdraw:
          type: boolean
          description: Show the Withdraw control — the caller is the submitter and
            the request is still pending.
          example: false
    FrontlineRequestDetail:
      type: object
      description: One intake request in full. Decision/campaign fields fill in as
        it moves through the funnel.
      properties:
        id:
          type: integer
          example: 31
        title:
          type: string
          example: Re-check cold chain after firmware fix
        status:
          type: string
          description: submitted / approved / declined / withdrawn.
          example: submitted
        priority:
          type: string
          example: important
        work_type:
          type: string
          nullable: true
          example: task
        work_type_label:
          type: string
          nullable: true
          example: Task
        instructions:
          type: string
          nullable: true
          example: Read both pharmacy fridges again and photograph the panel.
        note:
          type: string
          nullable: true
        estimated_duration_minutes:
          type: integer
          nullable: true
          example: 15
        desired_start_on:
          type: string
          format: date
          nullable: true
        desired_end_on:
          type: string
          format: date
          nullable: true
        desired_publish_on:
          type: string
          format: date
          nullable: true
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Compliance
        department:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 4
            name:
              type: string
              example: Pharmacy
        audience:
          type: object
          nullable: true
          description: The requested audience, or null ("Reviewer's call" on the web).
          properties:
            id:
              type: integer
              example: 12
            name:
              type: string
              example: 5 pharmacy stores
        target_location_count:
          type: integer
          description: How many stores the requester named directly (0 when they named
            an audience or left it to ops).
          example: 0
        target_locations:
          type: array
          description: The stores the requester named directly, resolved to id + name
            and ordered by name. Empty when the request names an audience or every
            location instead of a direct list. Tenant-scoped — a store id from another
            business is never surfaced here.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 42
              name:
                type: string
                example: Store 042 - Downtown
        requested_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 55
            name:
              type: string
              example: Anthony Rivera
        requested_at:
          type: string
          format: date-time
          nullable: true
        decided_by:
          type: object
          nullable: true
          description: Who decided it — present once approved or declined.
          properties:
            id:
              type: integer
              example: 3
            name:
              type: string
              example: Dana Okafor
        decided_at:
          type: string
          format: date-time
          nullable: true
        decline_reason:
          type: string
          nullable: true
          description: The reason that travels back to the requester — present only
            on a declined request.
        under_engine_review:
          type: boolean
          description: The request is sitting in a configured approval workflow (decided
            in the Approvals surface, not the queue's buttons).
          example: false
        campaign:
          type: object
          nullable: true
          description: The draft campaign the approval minted — present only once
            approved.
          properties:
            id:
              type: integer
              example: 88
            name:
              type: string
              example: Re-check cold chain after firmware fix
            status:
              type: string
              example: draft
        attachments:
          type: array
          description: The requester's files (expiring download URLs).
          items:
            type: object
            properties:
              id:
                type: integer
                example: 91
              filename:
                type: string
                example: planogram.pdf
              byte_size:
                type: integer
                example: 220100
              content_type:
                type: string
                example: application/pdf
              url:
                type: string
                example: https://.../rails/active_storage/...
    FrontlineExecutionRequestFormConfig:
      type: object
      description: The intake-request form's option lists.
      properties:
        categories:
          type: array
          description: The active categories offered by the form's category dropdown,
            ordered by (sort_order, name).
          items:
            "$ref": "#/components/schemas/FrontlineExecutionFormCategory"
      required:
      - categories
    FrontlineExecutionFormCategory:
      type: object
      description: One selectable category for the intake-request form's category
        dropdown.
      properties:
        id:
          type: integer
          example: 12
        name:
          type: string
          example: Planogram
        description:
          type: string
          nullable: true
          description: Optional helper text authored alongside the category; null
            when none was set.
          example: Weekly planogram resets
        color_token:
          type: string
          nullable: true
          description: Bootstrap subtle-badge token for the category chip; null when
            none was set.
          example: info
      required:
      - id
      - name
    FrontlineRequestCounts:
      type: object
      description: One badge per visible tab. `approval` is present only for a reviewer.
      properties:
        all:
          type: integer
          example: 12
        submitted:
          type: integer
          description: '"In review" — the caller''s own pending asks.'
          example: 3
        approved:
          type: integer
          example: 7
        declined:
          type: integer
          example: 2
        approval:
          type: integer
          description: Reviewer only — every pending ask in the business (the queue
            badge).
          example: 5
    FrontlineRequestListRow:
      description: One request row — the full request (as in the detail endpoint)
        plus the caller's per-row viewer.
      allOf:
      - type: object
        description: One intake request in full. Decision/campaign fields fill in
          as it moves through the funnel.
        properties:
          id:
            type: integer
            example: 31
          title:
            type: string
            example: Re-check cold chain after firmware fix
          status:
            type: string
            description: submitted / approved / declined / withdrawn.
            example: submitted
          priority:
            type: string
            example: important
          work_type:
            type: string
            nullable: true
            example: task
          work_type_label:
            type: string
            nullable: true
            example: Task
          instructions:
            type: string
            nullable: true
            example: Read both pharmacy fridges again and photograph the panel.
          note:
            type: string
            nullable: true
          estimated_duration_minutes:
            type: integer
            nullable: true
            example: 15
          desired_start_on:
            type: string
            format: date
            nullable: true
          desired_end_on:
            type: string
            format: date
            nullable: true
          desired_publish_on:
            type: string
            format: date
            nullable: true
          category:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 7
              name:
                type: string
                example: Compliance
          department:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Pharmacy
          audience:
            type: object
            nullable: true
            description: The requested audience, or null ("Reviewer's call" on the
              web).
            properties:
              id:
                type: integer
                example: 12
              name:
                type: string
                example: 5 pharmacy stores
          target_location_count:
            type: integer
            description: How many stores the requester named directly (0 when they
              named an audience or left it to ops).
            example: 0
          target_locations:
            type: array
            description: The stores the requester named directly, resolved to id +
              name and ordered by name. Empty when the request names an audience or
              every location instead of a direct list. Tenant-scoped — a store id
              from another business is never surfaced here.
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 42
                name:
                  type: string
                  example: Store 042 - Downtown
          requested_by:
            type: object
            nullable: true
            properties:
              id:
                type: integer
                example: 55
              name:
                type: string
                example: Anthony Rivera
          requested_at:
            type: string
            format: date-time
            nullable: true
          decided_by:
            type: object
            nullable: true
            description: Who decided it — present once approved or declined.
            properties:
              id:
                type: integer
                example: 3
              name:
                type: string
                example: Dana Okafor
          decided_at:
            type: string
            format: date-time
            nullable: true
          decline_reason:
            type: string
            nullable: true
            description: The reason that travels back to the requester — present only
              on a declined request.
          under_engine_review:
            type: boolean
            description: The request is sitting in a configured approval workflow
              (decided in the Approvals surface, not the queue's buttons).
            example: false
          campaign:
            type: object
            nullable: true
            description: The draft campaign the approval minted — present only once
              approved.
            properties:
              id:
                type: integer
                example: 88
              name:
                type: string
                example: Re-check cold chain after firmware fix
              status:
                type: string
                example: draft
          attachments:
            type: array
            description: The requester's files (expiring download URLs).
            items:
              type: object
              properties:
                id:
                  type: integer
                  example: 91
                filename:
                  type: string
                  example: planogram.pdf
                byte_size:
                  type: integer
                  example: 220100
                content_type:
                  type: string
                  example: application/pdf
                url:
                  type: string
                  example: https://.../rails/active_storage/...
      - type: object
        properties:
          viewer:
            type: object
            description: The caller's lens on this intake request and the actions
              they may take.
            properties:
              is_requester:
                type: boolean
                description: The caller submitted this request.
                example: false
              is_gatekeeper:
                type: boolean
                description: The caller is in the campaign-author tier that works
                  the queue.
                example: true
              can_approve:
                type: boolean
                description: Show the Approve control — the caller is a gatekeeper,
                  the request is still pending, and no configured approval workflow
                  owns the decision.
                example: true
              can_decline:
                type: boolean
                description: Show the Decline control — same condition as can_approve.
                example: true
              can_withdraw:
                type: boolean
                description: Show the Withdraw control — the caller is the submitter
                  and the request is still pending.
                example: false
    FrontlineRequestListMeta:
      type: object
      description: The standard pagination envelope, plus the filter-fallback disclosure.
      properties:
        total_count:
          type: integer
          example: 12
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
        filter_ignored:
          type: boolean
          description: Present and true when the requested filter was not applied
            (unknown, or `approval` for a non-reviewer).
          example: true
        filter_note:
          type: string
          description: Why the requested filter was not applied — present with filter_ignored.
          example: The `filter` value "approval" was not applied (the `approval` queue
            is only available to reviewers); showing all your requests instead.
    FrontlineRequestCreateBody:
      type: object
      description: The fields the "New request" form submits. Only `title` is required;
        `priority` defaults to `normal`.
      required:
      - title
      properties:
        title:
          type: string
          maxLength: 255
          example: Re-check cold chain after firmware fix
        instructions:
          type: string
          maxLength: 10000
          nullable: true
          description: What the floor should actually do — carried onto the campaign
            the approval mints.
          example: Read both pharmacy fridges again and photograph the panel.
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
          default: normal
          description: Defaults to `normal` when omitted (the web form pre-selects
            it).
        work_type:
          type: string
          nullable: true
          enum:
          - task
          - inspection
          - acknowledgement
          description: The kind of work asked for. Blank = "the requester didn't say,
            ops decides".
          example: task
        owning_department_id:
          type: integer
          nullable: true
          description: The requesting department (dropped to null if it isn't this
            tenant's).
        audience_id:
          type: integer
          nullable: true
          description: The requested audience (dropped to null if it isn't this tenant's).
            Null = "Reviewer's call".
        category_id:
          type: integer
          nullable: true
          description: The intake programme/category (dropped to null if it isn't
            this tenant's).
        estimated_duration_minutes:
          type: integer
          nullable: true
          minimum: 1
          maximum: 480
          description: How long one store should need (1–480 minutes).
          example: 15
        desired_start_on:
          type: string
          format: date
          nullable: true
        desired_end_on:
          type: string
          format: date
          nullable: true
          description: Must be on or after desired_start_on.
        desired_publish_on:
          type: string
          format: date
          nullable: true
        note:
          type: string
          maxLength: 2000
          nullable: true
          description: A free-text note to the reviewer.
        target_location_ids:
          type: array
          description: Stores the requester named directly. Intersected with the tenant's
            active physical sites — ids outside it are silently dropped (never fail
            the ask).
          items:
            type: integer
        store_list_paste:
          type: string
          nullable: true
          description: Pasted store numbers (one per line, or comma/tab separated).
            Resolved by external_id then exact name; the matched/unmatched split comes
            back in `store_list`.
          example: |-
            4102
            4118
            4210
        from_request_id:
          type: integer
          description: 'Ask again: the caller''s own DECLINED request this one re-sends.
            Every field this call leaves out is taken from it (a field sent as null
            is cleared), as is its store list when no store input is sent, and its
            files are attached again. An id that is not the caller''s declined request
            is refused (`invalid_from_request`, 422).'
        carry_attachment_ids:
          type: array
          description: 'With `from_request_id`: which of the declined request''s files
            to attach again (its attachment ids). Leave it out to carry all of them;
            an empty list carries none.'
          items:
            type: integer
    FrontlineRequestStoreListOutcome:
      type: object
      nullable: true
      description: How a pasted store list resolved. Present only when `store_list_paste`/`store_list_paste_file`
        was sent.
      properties:
        matched:
          type: integer
          description: How many pasted numbers matched a store.
          example: 2
        unmatched:
          type: array
          description: The unrecognised tokens (capped for display).
          items:
            type: string
          example:
          - '9999'
        unmatched_total:
          type: integer
          description: The true count of unrecognised tokens.
          example: 1
        has_more:
          type: boolean
          description: The unmatched list was capped for display.
          example: false
    ReviewDecisionItem:
      type: object
      description: The item after the decision — the same shape the item-action endpoints
        return.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          description: "`done` after accept, `reopened` after reject."
          example: done
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        handoff_count:
          type: integer
          example: 0
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
    ReviewDecisionError:
      type: object
      description: The standard error envelope for a refused decision.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: "`reason_required`, `not_submitted`, `self_review` or `campaign_closed`
                (reject only)."
              example: reason_required
            message:
              type: string
              example: Say what needs fixing — it's the only thing the person who
                did this work will see.
    ReviewItem:
      type: object
      description: One submitted item awaiting review, with its proof inline.
      properties:
        id:
          type: integer
          example: 90123
        campaign_id:
          type: integer
          example: 42
        campaign:
          type: string
          nullable: true
          example: Endcap reset — March
        title:
          type: string
          example: Reset the seasonal endcap
        location:
          type: string
          nullable: true
          example: Store 412 — Riverside
        location_id:
          type: integer
          nullable: true
          example: 3369
        status:
          type: string
          example: submitted
        priority:
          type: string
          nullable: true
          example: high
        due_at:
          type: string
          format: date-time
          nullable: true
        overdue:
          type: boolean
          example: false
        submitted_at:
          type: string
          format: date-time
          nullable: true
        submitted_by:
          type: object
          nullable: true
          description: The worker who submitted the proof.
          properties:
            id:
              type: integer
              example: 771
            name:
              type: string
              example: Dana Ruiz
        self_review_blocked:
          type: boolean
          description: True when this reviewer submitted the work themselves and separation
            of duties bars them from deciding it — accept/reject would be refused,
            so a client should show "someone else has to approve it".
          example: false
        sent_back:
          type: object
          nullable: true
          description: Present when this submission is the redo of work that was sent
            back for rework — the reason it was sent back and when — so a client can
            show the reviewer what was asked for beside the new proof. Null for a
            first submission.
          properties:
            reason:
              type: string
              example: Bay 2 still shows last week's prices — re-shoot it.
            sent_back_at:
              type: string
              format: date-time
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        proof:
          "$ref": "#/components/schemas/ReviewProof"
    ReviewProof:
      type: object
      description: The evidence the reviewer decides on.
      properties:
        requires_photo:
          type: boolean
          example: true
        requires_signature:
          type: boolean
          example: false
        completion_note:
          type: string
          nullable: true
          example: Left bay reshot as asked.
        photos:
          "$ref": "#/components/schemas/ReviewProofPhotos"
        signature:
          type: object
          nullable: true
          description: The captured signature, or null when none was required/collected.
          properties:
            image_data:
              type: string
              description: A `data:image/...` URL of the drawn signature.
            signed_by:
              type: string
              nullable: true
              example: Dana Ruiz
        ai_check:
          type: object
          nullable: true
          description: 'The cached AI vision verdict on the photos, or null when AI
            is off for the business or nothing has been checked. Advisory only: it
            never accepts or sends back work.'
          properties:
            verdict:
              type: string
              enum:
              - looks_good
              - concerns
              - unclear
              description: "`unclear` — the check couldn't tell from the photo. Checks
                stored before it existed read `concerns` for both."
              example: looks_good
            looks_good:
              type: boolean
              example: true
            summary:
              type: string
              example: Shelves match the planogram; facings are full.
            compared_with_reference:
              type: boolean
              nullable: true
              description: True when the photo was compared with the campaign's planogram
                image, false when checked against its instructions only, null for
                a check stored before this was recorded.
            reference:
              type: string
              nullable: true
              example: Compared with the campaign's planogram image.
            advisory:
              type: string
              example: A suggestion; the reviewer decides.
            checked_at:
              type: string
              format: date-time
              nullable: true
            stale:
              type: boolean
              description: True when the verdict predates the latest resubmission
                or was run against a different photo count — a client should re-run
                before trusting it.
              example: false
    ReviewProofPhotos:
      type: object
      description: The completion photos, most recent first, capped and disclosed.
      properties:
        attached:
          type: boolean
          example: true
        total:
          type: integer
          description: True count of this attempt's photos (>= shown.size). On a redo,
            the photos from before the send-back are not counted here — they are in
            `earlier_attempt`.
          example: 8
        has_more:
          type: boolean
          description: True when the strip was cut at the 6-photo cap.
          example: true
        shown:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 55010
              filename:
                type: string
                example: endcap-left.jpg
              url:
                type: string
                description: Full-size image URL (inline).
              thumb_url:
                type: string
                description: 140px thumbnail URL.
        earlier_attempt:
          type: array
          description: On a redo, the photos from before the work was sent back (the
            ones the reviewer turned down), most recent last and capped like `shown`
            — history to compare against, not this submission's proof. Empty for a
            first submission.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 55002
              filename:
                type: string
                example: endcap-left-before.jpg
              url:
                type: string
              thumb_url:
                type: string
    ReviewPaginationMeta:
      type: object
      description: The standard pagination envelope, plus the filter disclosure.
      properties:
        total_count:
          type: integer
          example: 37
        total_pages:
          type: integer
          example: 2
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        has_next_page:
          type: boolean
          example: true
        has_prev_page:
          type: boolean
          example: false
        campaign_filter_ignored:
          type: boolean
          description: Present and true only when an unknown `campaign_id` was dropped.
          example: true
        campaign_filter_note:
          type: string
          description: Human-readable reason the campaign filter was not applied.
    ReadyToCloseCampaign:
      type: object
      description: One active campaign whose work is fully resolved.
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: Endcap reset — March
        status:
          type: string
          example: active
        priority:
          type: string
          nullable: true
          example: normal
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Merchandising
        work_type:
          type: string
          example: task
        work_type_label:
          type: string
          example: Task
        close_behavior:
          type: string
          description: manual / on_end_date / on_all_complete.
          example: manual
        starts_on:
          type: string
          format: date
          nullable: true
        ends_on:
          type: string
          format: date
          nullable: true
        total:
          type: integer
          description: Total obligations in the campaign.
          example: 40
        done:
          type: integer
          nullable: true
          description: How many were signed off (done). Null while `people_withheld`
            is present.
          example: 37
        missed:
          type: integer
          nullable: true
          description: How many were missed (window closed). Null while `people_withheld`
            is present.
          example: 3
        people_withheld:
          type: object
          description: Knowledge Layer X3 (#4546) — present only on a post a works
            council covers whose caller's whole span holds fewer people than the company's
            smallest report group; `done` and `missed` are then null.
          properties:
            reason:
              type: string
              example: Fewer than 5 people — counted in the level above.
    IntelligenceSmartDefaults:
      type: object
      description: The caller's smart defaults. Keys after `source` depend on the
        time of day, the request's category and assistant type, and the caller's recent
        sessions; absent keys simply did not apply.
      required:
      - ai_assistant
      - communication_style
      - complexity_preference
      - session_duration
      - preferred_categories
      - peak_usage_hours
      - confidence_score
      - source
      - time_context
      - suggested_session_length
      - confidence_factors
      - generated_at
      - expires_at
      - user_profile_version
      - metadata
      properties:
        ai_assistant:
          type: string
          example: task_assistant
        communication_style:
          type: string
          example: moderate
        complexity_preference:
          type: string
          example: moderate
        session_duration:
          type: string
          example: medium
        preferred_categories:
          type: array
          items: {}
        peak_usage_hours:
          type: array
          description: Hours of the day (0-23).
          items:
            type: integer
        confidence_score:
          type: string
          description: Overall confidence 0..1, the mean of `confidence_factors`.
            A decimal, rendered as a string.
          example: '0.25'
        source:
          type: string
          enum:
          - postgresql_materialized_view
          - fallback_defaults
        time_context:
          type: string
          enum:
          - peak_productivity
          - off_peak
        suggested_session_length:
          type: string
          enum:
          - longer
          - shorter
        workload_context:
          type: string
          enum:
          - high
          - fresh
          description: "`high` after more than 3 sessions in the last hour, `fresh`
            after none; absent otherwise."
        suggested_complexity:
          type: string
        category_complexity:
          type: string
        category_duration:
          type: string
        category_style:
          type: string
        category_confidence:
          type: number
        assistant_satisfaction:
          type: string
          description: Decimal, as a string.
        assistant_usage_frequency:
          type: number
        assistant_preferred_complexity:
          type: string
        assistant_confidence:
          type: number
        focus_areas:
          type: array
          items:
            type: string
        suggested_prompts:
          type: array
          description: Up to three `[name, prompt_content]` pairs.
          items:
            type: array
            items:
              type: string
        interaction_style:
          type: string
          enum:
          - action_oriented
          - guidance_focused
          - analytical
          - detailed_technical
        field_assistance_level:
          type: string
          enum:
          - minimal
          - moderate
          - extensive
          description: "`form_assistant` only."
        query_complexity:
          type: string
          description: "`database_assistant` only."
        safety_level:
          type: string
          enum:
          - high
          description: "`admin_assistant` only."
        confidence_factors:
          type: object
          properties:
            data_volume:
              type: number
            pattern_consistency:
              type: string
              description: Decimal, as a string.
            recent_activity:
              type: number
            context_match:
              type: number
        generated_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        user_profile_version:
          type: string
          format: date-time
          description: When the caller's intelligence profile was last updated.
        metadata:
          type: object
          properties:
            total_interactions:
              type: integer
            pattern_confidence:
              type: string
              description: Decimal, as a string.
            last_analysis:
              type: string
              format: date-time
              nullable: true
            generation_method:
              type: string
              example: postgresql_native_with_context
    IntelligenceContextualSuggestion:
      type: object
      properties:
        type:
          type: string
          enum:
          - approach_suggestion
          - response_template
          - follow_up
          - goal
          example: follow_up
        content:
          description: 'The text the person could send: an approach or response template
            from one of their successful patterns, or a follow-up.'
          example: Walk me through it step by step
        confidence:
          type: number
        source:
          type: string
          enum:
          - similar_pattern
          - successful_pattern
          - conversation_context
          - target_outcome
        relevance:
          type: number
          description: Present on follow-ups; the relevance the ranking used.
        pattern_id:
          type: integer
          description: The interaction pattern a suggestion came from (pattern suggestions
            only).
        relevance_score:
          type: number
        combined_score:
          type: number
          description: 0.6 × relevance + 0.4 × confidence; the list is sorted by it,
            highest first.
        ranking_factors:
          type: object
          properties:
            context_match:
              type: number
            pattern_confidence:
              type: number
            user_preference_alignment:
              type: number
    IntelligencePromptSuggestion:
      type: object
      required:
      - id
      - title
      - content
      - category
      - complexity
      - intelligence_score
      - effectiveness_rating
      - personalization_score
      - estimated_tokens
      - success_rate
      properties:
        id:
          type: integer
        title:
          type: string
        content:
          type: string
        category:
          type: string
          nullable: true
        complexity:
          type: string
          nullable: true
        intelligence_score:
          description: The template's intelligence score.
        effectiveness_rating:
          description: The template's effectiveness rating.
        personalization_score:
          type: number
        estimated_tokens:
          type: integer
        success_rate:
          description: The template's recorded success rate, when it has one.
    IntelligenceBusinessPerformance:
      type: object
      properties:
        business_id:
          type: integer
        business_name:
          type: string
        total_users:
          type: integer
          description: People with an intelligence profile in the business.
        active_users:
          type: integer
          description: Of those, interacted in the last 7 days.
        avg_intelligence_score:
          description: Decimal as a string, or the number 0 when there are no profiles.
        total_interactions:
          type: integer
        avg_satisfaction:
          description: Decimal as a string, or the number 0 when no profile has a
            score.
        patterns_count:
          type: integer
        sessions_count:
          type: integer
        successful_sessions:
          type: integer
        recent_activity:
          type: object
          properties:
            sessions_last_24h:
              type: integer
            patterns_last_24h:
              type: integer
            active_users_last_24h:
              type: integer
        performance_trends:
          type: array
          description: One row per day for the last seven days.
          items:
            type: object
            properties:
              date:
                type: string
              sessions:
                type: integer
              successful_sessions:
                type: integer
              avg_satisfaction: {}
    LibraryItemBookmarkState:
      type: object
      description: The caller's bookmark state for one library item AFTER the call.
        Identical for both verbs, so a client can write the result straight onto the
        item card it is holding with no field mapping.
      required:
      - library_item_id
      - bookmarked
      properties:
        library_item_id:
          type: integer
          description: The library item id (echoes the path id).
          example: 4217
        bookmarked:
          type: boolean
          description: 'The bookmark state for THIS caller after the call — always
            `true` from `POST`, always `false` from `DELETE`. Per-user: it says nothing
            about whether anyone else has saved the item.'
          example: true
        unread_notification_count:
          type: integer
          description: Piggybacked on every response in this API for native badge
            management. Unrelated to Libraries.
          example: 3
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    LibraryCard:
      type: object
      description: One row of the All Libraries index. Every field the mockup's card
        renders, with the same fallbacks the web card applies — so the two surfaces
        cannot drift on an icon, a colour or a type label.
      required:
      - id
      - title
      - description
      - image
      - banner_fill
      - icon
      - color
      - library_type
      - library_type_label
      - categories_count
      - items_count
      - updated_at
      - enabled
      - can_disable
      - link
      properties:
        id:
          type: integer
          example: 42
        title:
          type: string
          description: The library's name.
          example: Company Policies
        description:
          type: string
          nullable: true
          description: The card's blurb. `null` (never `""`) when the library has
            none.
          example: Every published company policy, grouped by the team that owns it.
        image:
          type: string
          nullable: true
          description: ABSOLUTE URL of the library's banner image, or `null` when
            it has none — in which case paint `banner_fill`. Absolute because a native
            client cannot resolve a host-relative path.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/eyJf.../banner.png
        banner_fill:
          type: string
          description: 'The CSS gradient the web card paints behind a library with
            no banner image. Sent whole rather than derived client-side: the light
            stop is a hand-picked per-preset value, so `color` alone is not enough
            to reproduce it.'
          example: linear-gradient(110deg,#3f5372,#7c8fb0)
        icon:
          type: string
          description: Font Awesome class for the mark on the banner. Falls back to
            `fas fa-book-open` — the same default the web card uses — so this is never
            blank.
          example: fas fa-file-shield
        color:
          type: string
          description: The library's hex colour, driving both the icon tint and the
            banner fill. Falls back to the app default (`#2f64b1`), so this is never
            blank and never a non-hex value.
          example: "#3f5372"
        library_type:
          type: string
          enum:
          - mixed_content
          - images_and_videos
          description: The library kind, for client logic.
          example: mixed_content
        library_type_label:
          type: string
          description: The display wording the banner prints. Provided because `titleize`
            gets it wrong — `images_and_videos` reads "Images & Videos", not "Images
            And Videos".
          example: Mixed Content
        categories_count:
          type: integer
          description: Number of categories in this library (the folder icon on the
            card). Computed by a scalar subquery, so it costs nothing per row.
          example: 6
        items_count:
          type: integer
          description: Number of items across all of this library's categories (the
            document icon on the card). Also a scalar subquery.
          example: 28
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: ISO8601. The same timestamp `sort=recent` orders on and the
            card renders as "Updated N ago".
          example: '2026-08-29T06:32:39Z'
        enabled:
          type: boolean
          description: '`false` for a disabled library — draw the "Disabled" badge
            and grey the row. Only someone who can manage the library (a Libraries
            admin, a place-limited one in reach, its creator or a named `manager`)
            ever receives a `false` here.'
          example: true
        can_disable:
          type: boolean
          description: 'Whether this caller may take the availability verb THIS row
            offers: Disable on an enabled library (`POST /libraries/{id}/disable`,
            which asks a token `destructive:libraries`) or Enable on a disabled one
            (`POST /libraries/{id}/enable`, which asks `write:libraries`). False for
            anyone who is not a Libraries admin or a place-limited Libraries admin
            with this library in reach. So a token holding `write:libraries` alone
            sees true only on disabled rows.'
          example: true
        link:
          type: string
          nullable: true
          description: ABSOLUTE web URL of the library — for the row menu's Copy link,
            and for opening the library in a webview.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/42
    LibraryListMeta:
      type: object
      description: 'The canonical six-key pagination envelope (Api::V1::BaseController#build_pagination_meta),
        identical in shape to LibraryBookmarkPagination and LibrarySearchItemPagination.
        This schema declared only the first four until 2026-09-05: the endpoint was
        swept onto the shared helper without the contract being swept with it, so
        a client generated from this spec had no `has_next_page` and stopped paging
        at page 1 — the exact defect the code change closed.'
      required:
      - total_count
      - current_page
      - total_pages
      - per_page
      - has_next_page
      - has_prev_page
      properties:
        total_count:
          type: integer
          description: Rows matching the applied filter for this caller — equal to
            the `filter_counts` entry for `active_filter`.
          example: 8
        current_page:
          type: integer
          example: 1
        total_pages:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          description: "`current_page < total_pages`. Page until this is false; do
            not infer the end from a short page."
          example: false
        has_prev_page:
          type: boolean
          example: false
    LibraryBookmarkedItem:
      type: object
      description: One saved library item — everything a row, its 3-dot sheet and
        its details panel need, from one call.
      required:
      - id
      - title
      - item_type
      - item_type_label
      - icon_type
      - icon
      - breadcrumb
      - status
      - open_mode
      - bookmarked
      properties:
        id:
          type: integer
          description: The LIBRARY ITEM id — what the bookmark write endpoints take.
          example: 412
        title:
          type: string
          example: Remote Work Policy
        description:
          type: string
          nullable: true
        item_type:
          type: string
          description: The stored item kind. Branch logic on this, not on `item_type_label`.
          enum:
          - simple_link
          - file
          - form
          - survey
          - wiki
          - post
          - image
          - video
          example: file
        item_type_label:
          type: string
          description: |-
            Display label, EXTENSION-specific where the app can tell ("PDF", "Word", "Excel") and type-level otherwise ("Link", "Form", "Wiki"). The same label the web row prints.
            NAMED `item_type_label`, NOT `type_label`, since 2026-09-05: the column is `item_type`, every other label in this repo is `<prefix>_type_label` (including `library_type_label` on the library card and `item_type_label` on `GET /libraries/{id}`), and the bare spelling meant one concept arrived under two names depending on which Libraries endpoint a client called.
          example: PDF
        icon_type:
          type: string
          description: |-
            WHAT KIND OF STRING `icon` IS — the discriminator, because the glyph cannot carry its own domain. `default` and `custom` mean `icon` is a Font Awesome CLASS; `emoji` means `icon` is a raw emoji GRAPHEME.
            Read this before you render. A client that maps `icon` to an icon font unconditionally draws tofu for every emoji row, and one that prints it as text shows "fas fa-file-pdf" for the rest. No tenant holds an emoji row today, but Load Sample Data seeds three, so this is one button press from live rather than theoretical.
            Nullable, because the column is: `library_items.icon_type` has no NOT NULL and the model's enum takes `allow_nil`, so an explicitly cleared row is a legal state. Treat null as `default`.
          enum:
          - default
          - emoji
          - custom
          -
          nullable: true
          example: default
        icon:
          type: string
          description: The glyph — a Font Awesome class or an emoji grapheme, per
            `icon_type` above. Extension-specific where known; overridden by the item's
            own icon when an admin set one.
          example: fas fa-file-pdf
        icon_color:
          type: string
          description: Hex glyph colour.
          example: "#c0392b"
        icon_background:
          type: string
          description: Fill for the plate behind the glyph. Hex, or an `rgba()` string
            when derived from an admin-chosen item colour.
          example: "#fbe7e5"
        format:
          type: string
          nullable: true
          description: Lowercase file extension without the dot, or null when the
            item has nothing to infer one from (a form, a wiki page, a bare link).
          example: pdf
        library:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 7
            name:
              type: string
              example: Company Policies
            icon:
              type: string
              example: fas fa-shield-halved
            color:
              type: string
              example: "#3478f6"
            enabled:
              type: boolean
              description: False only for a Libraries administrator looking at a disabled
                library's saved item — grey the row and badge it "Disabled".
              example: true
        category:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 21
            name:
              type: string
              example: Leave & Time Off
            position:
              type: integer
              example: 0
        breadcrumb:
          type: string
          nullable: true
          description: "`Library › Category`, pre-joined with the separator the design
            uses."
          example: Company Policies › Leave & Time Off
        status:
          type: string
          description: "`available` — opens normally. `inactive` — the linked record
            was unpublished/deactivated after it was linked, OR this reader may not
            open it; show the details panel, not a link. `unavailable` — the linked
            record is gone; a library manager must relink or remove it."
          enum:
          - available
          - inactive
          - unavailable
          example: available
        status_label:
          type: string
          nullable: true
          description: The badge text the web renders beside the row ("Inactive",
            "Source unavailable"), or null when the item is healthy.
          example:
        url:
          type: string
          nullable: true
          description: |-
            Absolute destination — where a tap goes. NULL whenever `open_mode` is `unavailable`, including for an item this particular reader may not open even though a link exists for someone else.
            ALSO NULL when `open_mode` is `form`: a form is opened BY ID, from `form_id`. Branch on `open_mode`, never on which of the two is set.
        form_id:
          type: integer
          nullable: true
          description: 'The id of the linked form — the locator that replaced a form
            item''s web URL, for `open_mode: form` only. Open your own form screen
            with it. Null for every other type, and null for a form this reader may
            not open (same rule `url` follows: `status` / `status_label` say why).'
          example:
        copy_link_url:
          type: string
          nullable: true
          description: 'What "Copy link" puts on the clipboard: the absolute destination.
            The same value as `url` for every type EXCEPT a form, where it is the
            card''s only URL — the clipboard''s job is to produce something a person
            can paste, and a form id pastes into nothing. Null whenever `open_mode`
            is `unavailable`.'
        link_url:
          type: string
          nullable: true
          description: The source URL an admin stored on the item, which the details
            panel prints on its own row. Distinct from `url`, which is where a tap
            goes.
        opens_in:
          type: string
          nullable: true
          description: |-
            The item's configured link target — what the web anchor's `target` reads. `GET /libraries/{id}` carries the same column as `open.target`.
            NULLABLE, and a typed client must model it as optional. `library_items.link_target` is `t.string default: "new_tab"` with no NOT NULL, the model's enum takes `allow_nil`, and the item form deliberately submits an empty value — which casts back to nil — for every item type whose "Open link in" panel is never shown, so an unanswered question stays unanswered rather than being answered "New tab" on the admin's behalf. No row holds null today; the first one would crash a client with a non-optional String here.
          enum:
          - new_tab
          - current_tab
          -
          example: new_tab
        open_mode:
          type: string
          description: How to open it, and which of `url` / `form_id` carries the
            locator. `form` — a form this app hosts; route to your own form screen
            using `form_id` (`url` is null). `external` — an off-platform http(s)
            URL; hand it to the system browser. `preview` — a file this app serves;
            open the file preview or the media viewer. `in_app` — a path inside the
            platform (a wiki page, a survey, a document record). `unavailable` — nothing
            to open; show the details panel and `status_label` says why.
          enum:
          - form
          - external
          - preview
          - in_app
          - unavailable
          example: preview
        download_url:
          type: string
          nullable: true
          description: Absolute URL for the same blob served as an attachment, or
            null when the item has no file to save (a link, a form, a wiki page, or
            a document record that stores no blob of its own). Omit the Download row
            from the sheet when null rather than showing it inert.
        captions:
          type: string
          nullable: true
          description: The listing's captions marker for a VIDEO, the state the web
            and phone listings draw their "CC" chip from. `available` — the video
            has a caption track (draw "CC", spoken label "Captions available"); `coming`
            — a track is being generated (draw "Captions coming"). Null for every
            other item, and for a video with neither.
        captions_url:
          type: string
          nullable: true
          description: Absolute URL of that video's WebVTT caption track — fetch it
            with the same bearer token (GET /libraries/{library_id}/items/{id}/captions,
            `text/vtt`) and show the cues while the video plays. Set exactly when
            `captions` is `available`; null otherwise. The web player's `<track>`
            route answers only a browser session, so a native client must use this
            URL.
        media:
          type: object
          nullable: true
          description: The item's own uploaded file. Null when it has none.
          properties:
            filename:
              type: string
              example: remote-work.pdf
            content_type:
              type: string
              example: application/pdf
            byte_size:
              type: integer
              example: 1153434
            size_label:
              type: string
              description: Pre-formatted, matching what the details panel prints.
              example: 1.1 MB
            width:
              type: integer
              nullable: true
              description: |-
                Pixel width, ALWAYS an integer, and null — never zero — for a blob Active Storage has not analyzed yet.
                Integer is a coercion, not a passthrough: the two analyzers disagree on type. The image analyzer writes vips' Integer while the VIDEO analyzer writes `Float(video_stream["width"])` by design, and this key used to ship whichever the row happened to hold — 2,000 image blobs as Integer, 149 video blobs as Float, under one key. Since 2026-09-05 both are coerced, which is safe because a pixel count is a whole number in either analyzer.
              example: 2400
            height:
              type: integer
              nullable: true
              description: Pixel height. Integer on the same terms as `width`.
              example: 1600
            dimensions:
              type: string
              nullable: true
              description: '`width × height`, or null when either is unknown. Integral
                on both sides — it read "1920.0 × 1080.0" for every video until the
                coercion above landed.'
              example: 2400 × 1600
            duration_seconds:
              type: number
              nullable: true
              description: Video length. Null until background analysis has run on
                a freshly uploaded file — that is a real "not yet known", not a zero.
              example: 92.4
            duration_label:
              type: string
              nullable: true
              description: "`m:ss` or `h:mm:ss`."
              example: '1:32'
            url:
              type: string
              description: Absolute URL for the blob served INLINE (preview / viewer).
        created_by:
          type: object
          nullable: true
          description: 'Who added the item — the details panel''s "Added by" byline.
            NAMED `created_by`, NOT `added_by`, since 2026-09-05: those are the names
            the columns actually have, and the names every other payload in this namespace
            uses (`GET /libraries/{id}` for both the item and the library). `added_by`
            / `added_at` were the only two occurrences of that spelling in the whole
            `/api/v1` surface — 2 sites against 254 — so a client decoding "who added
            this, and when" needed two field names depending on the endpoint.'
          properties:
            id:
              type: integer
              example: 88
            name:
              type: string
              example: Neha Kulkarni
        created_at:
          type: string
          format: date-time
          nullable: true
          description: When the ITEM was added to the library. Not to be confused
            with `bookmark.created_at`, which is when THIS CALLER saved it — the key
            this list is ordered by.
        updated_by:
          type: object
          nullable: true
          description: Null until somebody edits the item.
          properties:
            id:
              type: integer
            name:
              type: string
        updated_at:
          type: string
          format: date-time
          nullable: true
        can_manage:
          type: boolean
          description: May this caller edit / move / delete items in this library.
            Gates the 3-dot sheet's Edit, Move and Delete rows; it follows the library's
            management level, not the caller's role alone.
          example: false
        can_delete:
          type: boolean
          description: 'May this caller delete the item from the 3-dot sheet: `can_manage`
            AND the token holds `destructive:libraries`, which `DELETE /libraries/{library_id}/items/{id}`
            asks. Draw Delete off this flag; Edit and Move stay on `can_manage` (web
            handoffs).'
          example: false
        bookmarked:
          type: boolean
          description: Always true on this endpoint. Present so one card type serves
            every list.
          example: true
        bookmark:
          type: object
          nullable: true
          description: The caller's own bookmark row.
          properties:
            id:
              type: integer
              description: The BOOKMARK id (not the item id). Useful for local ordering;
                the bookmark write endpoints address the ITEM, not this.
              example: 9021
            note:
              type: string
              nullable: true
              description: The caller's own note, authored on the web /bookmarks page.
              example: Read before the audit
            created_at:
              type: string
              format: date-time
              description: When it was saved — the sort key of this list.
    LibraryBookmarkGroup:
      type: object
      description: |-
        One library the caller has saved something in, for the grouped Saved screen's header row. Deliberately a 7-key header rather than a full LibraryCard: it carries `bookmarked_count`, which the card has nowhere to put, and a card here would ship a signed banner URL, `link`, `banner_fill` and `description` per group header on the endpoint whose other defect was over-shipping this very array.
        Sent on PAGE 1 ONLY — see `groups_total` on the response and the file header.
      required:
      - id
      - title
      - bookmarked_count
      properties:
        id:
          type: integer
          example: 7
        title:
          type: string
          description: 'The library''s name. NAMED `title`, NOT `name`, since 2026-09-05:
            every other library representation in this namespace calls it `title`
            (the shared LibraryCard behind `/libraries/list`, `/libraries/search`
            and `GET /libraries/{id}`), and this hand-built group row was the one
            place the same column came back under a different key — so a client with
            one decoded `Library` type could not read its own Saved screen. A non-optional
            `title` in Swift or Kotlin is a hard decode failure, which takes down
            the whole screen rather than one row.'
          example: Company Policies
        icon:
          type: string
          example: fas fa-shield-halved
        color:
          type: string
          example: "#3478f6"
        library_type:
          type: string
          enum:
          - mixed_content
          - images_and_videos
          example: mixed_content
        enabled:
          type: boolean
          example: true
        bookmarked_count:
          type: integer
          description: The caller's saved items in this library across the WHOLE set,
            not just this page.
          example: 1
    LibraryBookmarkPagination:
      type: object
      properties:
        total_count:
          type: integer
          description: Saved items the caller can actually see. Deleted items and
            libraries out of reach are excluded here too, so this is the number the
            "N bookmarked items" line should print.
          example: 2
        total_pages:
          type: integer
          example: 1
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
    LibraryDetail:
      type: object
      description: The whole library screen in one payload.
      required:
      - library
      - sort
      - available_sorts
      - categories
      - meta
      properties:
        library:
          "$ref": "#/components/schemas/LibraryDetailHeader"
        sort:
          type: string
          enum:
          - default
          - az
          - recent
          description: The order that was APPLIED, always a canonical key — so a client
            that sent the `updated` alias, or an unrecognised value, can tell what
            it actually got.
          example: default
        available_sorts:
          type: array
          description: The orders this endpoint accepts, for the order dropdown.
          items:
            type: string
          example:
          - default
          - az
          - recent
        categories:
          type: array
          description: The library's categories, in the order the applied `sort` puts
            them (position order under `default`). Empty categories are INCLUDED,
            so the response never hides structure. Empty array for a library with
            no categories, and for a `category_id` that is not in this library.
          items:
            "$ref": "#/components/schemas/LibraryDetailCategory"
        meta:
          "$ref": "#/components/schemas/LibraryDetailMeta"
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    LibraryDetailHeader:
      type: object
      description: |-
        The library card `/libraries/list` returns, plus the settings and capability flags only the detail screen needs. Field names and fallbacks are shared with that endpoint, so the two surfaces cannot drift on a name, a cover, a mark or a type label.
        `visibility`, `management_level` and `created_by` are MANAGER-ONLY and are omitted entirely — not nulled — when `can_manage_library` is false. They are correspondingly absent from `required` below.
      required:
      - id
      - title
      - description
      - image
      - banner_fill
      - icon
      - color
      - library_type
      - library_type_label
      - categories_count
      - items_count
      - enabled
      - can_disable
      - can_manage_items
      - can_manage_library
      - can_reorder_categories
      properties:
        id:
          type: integer
          example: 312
        title:
          type: string
          example: Company Policies
        description:
          type:
          - string
          - 'null'
          description: Null rather than an empty string when unset.
          example: Everything HR publishes
        image:
          type:
          - string
          - 'null'
          description: Absolute URL of the library's banner image, or null when none
            is attached — in which case a client paints `banner_fill`.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/redirect/…/banner.png
        banner_fill:
          type: string
          description: The exact CSS gradient the web hero paints behind a library
            with no banner image. Sent whole because its light stop is a hand-picked
            per-preset value — a client could NOT derive it from `color`.
          example: linear-gradient(110deg,#3f5372,#7c8fb0)
        icon:
          type: string
          description: Font Awesome class. Defaults to `fas fa-book-open`.
          example: fas fa-file-shield
        color:
          type: string
          description: Hex colour, defaulted through the same helper the web card
            uses.
          example: "#3f5372"
        library_type:
          type: string
          enum:
          - mixed_content
          - images_and_videos
        library_type_label:
          type: string
          description: The product wording, which `titleize` gets wrong — "Images
            & Videos", not "Images And Videos".
          example: Mixed Content
        visibility:
          type: string
          enum:
          - all_users
          - specific_audiences
          description: |-
            MANAGER-ONLY. The key is ABSENT — not null — unless `can_manage_library` below is true.
            `visibility` and `management_level` are the library's ACCESS RULES, and they shipped to every reader until 2026-09-04. Together they hand a plain member a map of which libraries in the tenant are restricted and, worse, exactly which carry `management_level: anyone` — i.e. where item writes are open to every reader. Neither is rendered to a non-manager on the web either; both live in the library form, behind the same predicate.
            A manager who receives them may not necessarily EDIT them: that is a second, stricter predicate (`SpacesController#library_audience_editable?` — admin, app-admin, or the library's creator), and it has no flag in this payload yet, so a client must not infer write access from the key's presence.
        management_level:
          type: string
          enum:
          - anyone
          - admins_and_specific
          - admins_only
          - domain_admins_only
          description: MANAGER-ONLY, on exactly the same terms as `visibility` above
            — absent unless `can_manage_library` is true, and read-only for some of
            the managers who receive it.
        default_view_mode:
          type: string
          enum:
          - grid
          - large
          - compact
          description: The library's own default, which SEEDS a new category's layout
            and does not override an existing one. Every category below carries the
            `view` it was actually given; read that, not this, to render.
        show_category_icons:
          type: boolean
          description: When false, a client omits the category heading's icon entirely.
        show_item_icons:
          type: boolean
          description: When false, a client omits the per-item mark entirely.
        categories_count:
          type: integer
          description: |-
            The library's TRUE number of categories — the same value `/libraries/list` and `/libraries/search` return on the shared LibraryCard, and unaffected by `?category_id=`.
            It used to be overridden here with a count of what the response happened to carry, which made a deep link report that the library held almost nothing: measured 2026-09-05 on a library truly holding 3 categories / 180 items, `/list` said 3 and 180, `GET /{id}` said 3 and 180, and `GET /{id}?category_id=…` said **1 and 60** — same library, same field, same session. A client that cached the card from the list and refreshed it from a deep link overwrote a correct total with a scoped one and had no field left to recover the real number from.
            The response-scoped pair still exists, under names that say so: `meta.categories_count` / `meta.items_count`.
          example: 6
        items_count:
          type: integer
          description: The library's TRUE number of items across every category, on
            the same terms as `categories_count` above. Not the length of anything
            in this payload — that is `meta.items_count`.
          example: 28
        created_by:
          allOf:
          - "$ref": "#/components/schemas/LibraryUserRef"
          description: |-
            MANAGER-ONLY. The key is ABSENT — not null — unless `can_manage_library` below is true, on exactly the same terms as `visibility` and `management_level` above.
            WHO CREATED A LIBRARY IS AN AUTHORIZATION FACT here, not provenance: `libraries.created_by_id` is read by `Libraries::Access#can_manage_library?` and by the stricter `SpacesController#library_audience_editable?`, so this field names, per library, the one person outside the admin tier who can rewrite that library's audience. It moved into `DetailsController#manager_only_audience_payload` on 2026-09-05, alongside the two audience fields that moved there on 2026-09-04.
            This schema still listed it as an unconditional field until 2026-09-06, so a spec-generated client bound a byline to a key a plain reader never receives. (The item-level `created_by` in `LibraryItem` further down is a different field — a plain byline, sent to every reader.)
        created_at:
          type:
          - string
          - 'null'
          format: date-time
        updated_at:
          type:
          - string
          - 'null'
          format: date-time
        enabled:
          type: boolean
          description: False for a disabled library. Only a caller who can manage
            the library reaches this payload at all in that state (everyone else 404s),
            so `false` here means "badge it Disabled and offer Enable".
        can_disable:
          type: boolean
          description: 'Whether this caller may flip the library''s enabled state
            (a Libraries admin, or a place-limited one with this library in reach)
            — the authority `POST /libraries/{id}/disable` and `…/enable` gate on,
            AND the scope of the verb this library offers: Disable asks a token `destructive:libraries`,
            Enable asks `write:libraries`.'
        can_manage_items:
          type: boolean
          description: Gates Add Item and the item kebab's Edit / Move / Delete. True
            for a Libraries admin, for anyone who can view an `anyone`-managed library,
            and for a contributor or manager under `admins_and_specific`.
        can_manage_library:
          type: boolean
          description: Gates Add Category, Edit library and Reorder categories. True
            for a Libraries admin and for a named `manager`.
        can_reorder_categories:
          type: boolean
          description: |-
            Offered only when the response is actually SHOWING the order those stored positions control: `can_manage_library` AND `sort` is `default` AND there is more than one category. Under `az` or `recent` the admin would be arranging rows whose new order the screen in front of them never reflects.
            THERE IS NO REORDER ENDPOINT IN THIS NAMESPACE. This flag answers "may you"; `link` below answers "where" — the Reorder categories dialog is a modal on the library page, so the page is its address. No separate `manage_url` is minted at this level because it would hold the identical string. See `LibraryItemActions`.
        link:
          type:
          - string
          - 'null'
          description: Absolute web URL for the library — for Copy link, for a webview
            hand-off, and the destination for `can_reorder_categories` / `can_manage_library`
            above, whose actions (Reorder categories, Add category, Edit library,
            Add item) all live on that page and have no endpoint here.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/312
    LibraryDetailCategory:
      type: object
      description: One category block. `view` and `sort_order` are the admin-owned
        columns that decide how this section renders and sorts for EVERY reader.
      required:
      - id
      - name
      - icon
      - position
      - view
      - view_label
      - sort_order
      - items_sorted_by
      - items_count
      - items_truncated
      - can_reorder_items
      - manage_url
      - items
      properties:
        id:
          type: integer
          example: 908
        name:
          type: string
          example: Site Procedures
        icon:
          type: string
          description: Font Awesome class. Defaults to `fas fa-folder`.
          example: fas fa-clipboard-check
        position:
          type: integer
          description: The category's stored order within the library.
        view:
          type: string
          enum:
          - compact
          - large
          - grid
          description: The layout this category renders in — `compact` (title-only
            rows), `large` (rows with description and meta line) or `grid` (thumbnail
            tiles). Set by an admin on the category and identical for every reader;
            there is no per-user view switcher.
        view_label:
          type: string
          description: The product wording for `view`, as the category form offers
            it.
          example: Large
        sort_order:
          type: string
          enum:
          - admin_defined
          - a_to_z
          - z_to_a
          description: The category's OWN ordering. `admin_defined` is the hand-ordered
            sequence the Reorder dialog writes to each item's `position`. This is
            what `sort=default` honours per category.
        items_sorted_by:
          type: string
          enum:
          - admin_defined
          - a_to_z
          - z_to_a
          - az
          - recent
          description: The rule that actually produced the `items` array below — this
            category's `sort_order` under `sort=default`, otherwise the page-wide
            override. Without it a client showing "Sorted A→Z" per section could not
            tell whether that was the reader's choice or the admin's.
        items_count:
          type: integer
          description: The category's TRUE item total — NOT the length of `items`,
            which is capped at 500 (`MAX_ITEMS_PER_CATEGORY`). The two are equal unless
            `items_truncated` is true. Zero for an empty category, which is still
            returned.
        items_truncated:
          type: boolean
          description: |-
            True when this category holds more than 500 items and `items` below therefore carries only the first 500 in the applied order — `items_count` keeps the real number.
            The cap exists so one GET cannot be an unbounded read of a whole library; it is far above anything the product produces (measured across every tenant 2026-09-04: the largest category holds 60 items), so it truncates nothing that exists today. Read the flag anyway — the rule here is that truncation is REPORTED, never silent, because a client quietly missing half a category is a worse bug than the unbounded read the cap replaced.
          example: false
        can_reorder_items:
          type: boolean
          description: |-
            Offered only when the response is showing the hand-ordered sequence: `can_manage_items` AND `sort` is `default` AND this category's `sort_order` is `admin_defined` AND it holds more than one item. The same four-part gate the web kebab applies.
            THERE IS NO REORDER ENDPOINT IN THIS NAMESPACE — this flag answers "may you", and `manage_url` below answers "where". See that field.
        manage_url:
          type:
          - string
          - 'null'
          description: |-
            Absolute web URL for this category's block on the library page — where its kebab (Edit category, Reorder items) lives. The dialog `can_reorder_items` refers to is a modal rendered into that page rather than a screen with an address of its own, so this is the library page anchored at `#category-{id}`.
            Null unless the caller can manage this library or its items — the same predicates the web page applies, so the URL is never a page its recipient would be bounced off. The KEY is always present; read the null, do not test for the key.
            Hand it to a web view or the system browser. See `LibraryItemActions` for why the management flags in this response ship URLs instead of API verbs.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/312#category-908
        items:
          type: array
          items:
            "$ref": "#/components/schemas/LibraryDetailItem"
    LibraryDetailItem:
      type: object
      description: 'One item, carrying everything the mockup''s three surfaces read:
        the row or tile, the ⋯ actions sheet, and the "View details" drawer that sheet
        opens.'
      required:
      - id
      - title
      - description
      - position
      - item_type
      - item_type_label
      - display_label
      - icon
      - icon_color
      - icon_background
      - status
      - bookmarked
      - manage_url
      - actions
      properties:
        id:
          type: integer
          example: 4217
        title:
          type: string
          example: Working at Height — Safety Harness Procedure
        description:
          type:
          - string
          - 'null'
          description: Null rather than an empty string when unset. Rendered by the
            `large` layout.
        position:
          type: integer
          description: The item's stored order within its category.
        item_type:
          type: string
          enum:
          - simple_link
          - file
          - form
          - survey
          - wiki
          - post
          - image
          - video
        item_type_label:
          type: string
          description: The human word for `item_type`.
          example: File
        display_label:
          type: string
          description: 'What the row''s plate and the tile''s panel actually PRINT:
            the FORMAT where it can be told ("PDF", "Excel", "Word"), the type otherwise
            ("Link", "Form"). On a phone row the format is the useful signal, not
            the generic word "File".'
          example: PDF
        icon:
          type: string
          description: Font Awesome class, resolved by the same helper the web listing
            uses — format-specific where possible, and overridden by the item's own
            icon when an admin set one.
          example: fas fa-file-pdf
        icon_color:
          type: string
          description: Hex tint for the glyph. The item's own `icon_color` wins when
            set.
          example: "#c0392b"
        icon_background:
          type: string
          description: Fill for the plate behind the glyph. Hex from the type/format
            palette, or an `rgba()` wash of the item's own colour when one is set.
          example: "#fbe7e5"
        thumbnail_url:
          type:
          - string
          - 'null'
          description: Absolute URL of the picture to show instead of the plate. Present
            for an IMAGE item with a real raster attached; null for everything else
            — no page rendering or video poster is invented for a document or a video.
        library_id:
          type: integer
        library_name:
          type: string
        category_id:
          type: integer
        category_name:
          type: string
        breadcrumb:
          type: string
          description: '"Library › Category" — the actions sheet''s header line, pre-composed
            so a client does not re-join it.'
          example: Company Policies › Site Procedures
        status:
          type: string
          enum:
          - available
          - inactive
          - unavailable
          description: The state of the record this item points at. `inactive` — the
            linked form / survey / wiki page exists but is not in its openable state
            (unpublished, draft, deactivated). `unavailable` — the linked record is
            gone. `available` for a healthy item and for any item with nothing linked.
        status_label:
          type:
          - string
          - 'null'
          description: The badge text for a bad state ("Inactive", "Source unavailable"),
            null when `available`.
        status_message:
          type:
          - string
          - 'null'
          description: The details drawer's own explanatory sentence for a bad state,
            so a client does not re-word it. Null when `available`.
        created_by:
          "$ref": "#/components/schemas/LibraryUserRef"
        created_at:
          type:
          - string
          - 'null'
          format: date-time
          description: The drawer's "Added by … · date" row.
        updated_by:
          "$ref": "#/components/schemas/LibraryUserRef"
        updated_at:
          type:
          - string
          - 'null'
          format: date-time
        file:
          oneOf:
          - "$ref": "#/components/schemas/LibraryItemFile"
          - type: 'null'
          description: 'The uploaded blob''s own description — the drawer''s Format
            / Size / Dimensions / Duration rows. NULL for anything with no blob of
            its own: a link, a form, a wiki item, and a `file` item that points at
            an HR File Manager record rather than carrying an upload (reaching that
            record''s metadata means walking a version chain once per row, which is
            an N+1 a list endpoint must not ship — such an item still carries `open`,
            which is where the document is served from).'
        captions:
          type:
          - string
          - 'null'
          description: The listing's captions marker for a VIDEO, the state the web
            and phone listings draw their "CC" chip from. `available` — the video
            has a caption track (draw "CC", spoken label "Captions available"); `coming`
            — a track is being generated (draw "Captions coming"). Null for every
            other item, and for a video with neither.
        captions_url:
          type:
          - string
          - 'null'
          description: Absolute URL of that video's WebVTT caption track — fetch it
            with the same bearer token (GET /libraries/{library_id}/items/{id}/captions,
            `text/vtt`) and show the cues while the video plays. Set exactly when
            `captions` is `available`; null otherwise. The web player's `<track>`
            route answers only a browser session, so a native client must use this
            URL.
        link_url:
          type:
          - string
          - 'null'
          description: The item's stored source URL — the drawer's "URL" row. For
            a simple link this is the same as `open.url`; for other types it is the
            raw field an admin typed. Null when unset.
        open:
          oneOf:
          - "$ref": "#/components/schemas/LibraryItemOpen"
          - type: 'null'
          description: Where and how the item opens, or NULL when there is nowhere
            to go — a dangling linked record, an unattached media item, a non-http
            link, or a record this caller may not reach. A client must treat null
            as "render the title as plain text", never as an error; that is exactly
            what the web listing does.
        copy_link_url:
          type:
          - string
          - 'null'
          description: |-
            What the kebab's "Copy link" puts on the clipboard: the destination made ABSOLUTE, because a copied link is pasted elsewhere and a host-less path is broken the moment it lands. Null whenever `open` is.
            This stays a URL for a FORM item, whose `open` block carries only a `form_id` — the clipboard's job is to produce something a person can paste, and an id pastes into nothing.
        download:
          oneOf:
          - "$ref": "#/components/schemas/LibraryItemDownload"
          - type: 'null'
          description: The item's own blob, served with an attachment disposition
            — the same blob `open` shows, saved rather than displayed. Null when the
            item carries no upload.
        bookmarked:
          type: boolean
          description: Whether THIS caller has saved the item. Per-user, and the same
            platform bookmark `POST`/`DELETE /libraries/items/{id}/bookmark` writes,
            so the flag and those verbs cannot disagree.
        manage_url:
          type:
          - string
          - 'null'
          description: |-
            Absolute web URL for this item's edit form — the destination for BOTH `actions.edit` and `actions.move`, which are one screen: the form carries the Category select, so moving an item is an edit of its category rather than a second page.
            NOT a second copy of `open` / `copy_link_url`. Those point at the item's DESTINATION — the wiki page, the form, the blob — while this points at the Libraries management UI. A client that acted on `actions.edit` previously had nothing in the response to navigate to.
            Null unless `actions.edit` is true (the same `can_manage_items` predicate the web controller applies), so it is never a page its recipient would be bounced off. The KEY is always present; read the null, do not test for the key.
            Hand it to a web view or the system browser. See `LibraryItemActions` for why.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/312/items/4471/edit
        actions:
          "$ref": "#/components/schemas/LibraryItemActions"
    LibraryItemFile:
      type: object
      description: The uploaded blob's format, size and media properties.
      properties:
        filename:
          type: string
          example: harness-procedure.pdf
        extension:
          type:
          - string
          - 'null'
          description: Lowercase, no dot. Null for a format the style table has no
            entry for.
          example: pdf
        format_label:
          type: string
          description: The human word for the format, the same one the tile's plate
            prints.
          example: PDF
        content_type:
          type: string
          example: application/pdf
        byte_size:
          type: integer
          example: 1258291
        size_label:
          type: string
          description: Human-readable size, pre-formatted.
          example: 1.2 MB
        width:
          type:
          - integer
          - 'null'
          description: From the Active Storage analyzer. NULL — not zero — for a blob
            that has not been analyzed yet, so a client renders nothing rather than
            "0 × 0".
          example: 2400
        height:
          type:
          - integer
          - 'null'
          example: 1600
        dimensions:
          type:
          - string
          - 'null'
          description: Pre-composed "W × H", null unless both are known.
          example: 2400 × 1600
        duration_seconds:
          type:
          - number
          - 'null'
          description: Video / audio only, from the analyzer.
          example: 198.4
        duration_label:
          type:
          - string
          - 'null'
          description: "`duration_seconds` as m:ss."
          example: '3:18'
    LibraryItemOpen:
      type: object
      description: |-
        The resolved destination and how a client should present it.
        TWO SHAPES, told apart by `mode`. A `form` item is opened BY ID: it carries `form_id` and NO `url` / `path`, because a client routes to its own form screen and a web path it would have to parse was useless to it. Everything else carries `url` + `path` and no `form_id`. Branch on `mode`, never on which key happens to be present.
        A form's pasteable web URL is still available — as the item's `copy_link_url`, whose job is to produce something a person can paste.
      required:
      - mode
      properties:
        url:
          type: string
          description: 'Absolute destination URL. ABSENT for `mode: form` — use `form_id`.'
        path:
          type: string
          description: 'The same destination as this app resolved it — a path for
            anything served in-app, an absolute URL for an external link. Provided
            so a native client can route in-app destinations itself instead of re-parsing
            `url`. ABSENT for `mode: form`.'
        form_id:
          type: integer
          description: 'The id of the linked form, for `mode: form` only — the locator
            that replaced this item''s form URL. Open your own form screen with it.
            Absent for every other mode.'
          example: 583
        target:
          type:
          - string
          - 'null'
          enum:
          - new_tab
          - current_tab
          -
          description: |-
            The admin's own choice on the item, which the web anchor's `target` reads. The same column `/search` and `/bookmarks` return as `opens_in`.
            NULLABLE, and a typed client must model it as optional. `library_items.link_target` is `t.string default: "new_tab"` with no NOT NULL, the model's enum takes `allow_nil`, and the item form deliberately submits an empty value — which casts back to nil — for every item type whose "Open link in" panel is never shown, so an unanswered question stays unanswered rather than being answered "New tab" on the admin's behalf. No row holds null today; the first one would crash a client with a non-optional String here.
        mode:
          type: string
          enum:
          - form
          - preview
          - external
          - in_app
          description: |-
            Which presenter to use, without parsing the URL. `form` — a form this app hosts; route to your form screen using `form_id` (there is no `url`). `preview` — an image or video item with an attached blob, for the file preview / full-screen media viewer. `external` — an absolute URL that belongs in the system browser. `in_app` — a path this app serves itself.
            `preview`, NOT `media`, since 2026-09-05. The card serializer behind `/search` and `/bookmarks` has always spelled this state `preview` in its own `open_mode`; this was the one token of the four where the two surfaces disagreed, so a client switching on the mode string mis-routed an uploaded file depending on which endpoint the item came from.
            There is no `unavailable` member here, and that is not an omission: where the card's `open_mode` says `unavailable`, this endpoint sends `open: null` instead. Treat a null `open` as "render the title as plain text" and read `status` / `status_message` for why.
            One caveat while the two implementations converge: `preview` is narrower here than on the card. This endpoint requires an `image` or `video` item, so a `file` item carrying its own uploaded PDF answers `in_app` here and `preview` from `/bookmarks` and `/search`. Either way the blob is served by this app — route on `mode` per endpoint, and use `download` / `file` to decide whether there is something to preview at all.
        description:
          type: string
          description: The actions sheet's sub-label under "Open" — per type, and
            naming the bad state first when there is one ("Inactive — opens details").
          example: Opens the file preview
    LibraryItemDownload:
      type: object
      required:
      - url
      - filename
      properties:
        url:
          type: string
          description: Absolute blob URL with an attachment disposition.
        filename:
          type: string
          example: harness-procedure.pdf
        format_label:
          type: string
          example: PDF
    LibraryItemActions:
      type: object
      description: |-
        The ⋯ kebab's option set, ALREADY GATED — so a client renders the menu without re-implementing a single permission rule. Everything above the divider belongs to any viewer; `edit`, `move` and `delete` need `can_manage_items`.
        "Reorder items" is deliberately NOT here: it reorders a CATEGORY, so it is answered once as `can_reorder_items` on the category rather than repeated identically on every item in it.
        ### Which of these has an API endpoint, and where the rest go
        This namespace is a READ API with two writes. The complete `/api/v1/libraries` route set is nine entries: `GET /libraries/list`, `GET /libraries/search`, `GET /libraries/bookmarks`, `POST` and `DELETE /libraries/items/{id}/bookmark`, `POST /libraries/{id}/disable`, `POST /libraries/{id}/enable`, `DELETE /libraries/{library_id}/items/{id}`, and this `GET /libraries/{id}`. There is no `PATCH` or `PUT` anywhere, no item-create `POST`, no move and no reorder.
        So of the eight flags below:
        * `bookmark` and `delete` have a verb —
          `POST`/`DELETE /libraries/items/{id}/bookmark` and
          `DELETE /libraries/{library_id}/items/{id}`.
        * `open`, `copy_link`, `download` and `details` need none: they are
          answered by `open`, `copy_link_url`, `download` and this payload
          itself.
        * **`edit` and `move` have none.** They are true, correctly-gated
          answers to "may this caller do it", and the action is performed on the
          web — so the item ships `manage_url`, the absolute URL of the form
          that performs both. The flag says whether to draw the row;
          `manage_url` says where the tap goes. Hand it to a web view or the
          system browser.

        The same split applies to the two `can_reorder_*` flags: neither has an endpoint, `can_reorder_items` hands off through the category's `manage_url`, and `can_reorder_categories` through the library's `link`.
        Do NOT read `edit: true` as "there is a PATCH for this". If write endpoints are added later these flags keep their meaning and `manage_url` stays — a URL to the full management screen is worth having next to a narrow write verb — and this section is what will change.
      required:
      - open
      - bookmark
      - details
      - copy_link
      - download
      - edit
      - move
      - delete
      properties:
        open:
          type: boolean
          description: False when `open` is null — there is nowhere to go.
        bookmark:
          type: boolean
          description: Always true. The toggle is per-user and every viewer holds
            it.
        details:
          type: boolean
          description: Always true — the drawer renders from this very payload, so
            it needs no further call.
        copy_link:
          type: boolean
          description: False when there is no destination to copy.
        download:
          type: boolean
          description: True only when the item carries its own uploaded blob.
        edit:
          type: boolean
          description: Needs `can_manage_items`. NO API endpoint — performed at the
            item's `manage_url`, which is non-null exactly when this is true.
        move:
          type: boolean
          description: |-
            "Move to category…". Needs `can_manage_items` AND somewhere else to move to — asked of the LIBRARY, not of the request, so it stays true under `?category_id=` scoping.
            NO API endpoint, and not a separate screen either: it is performed at the item's `manage_url`, the same edit form as `edit`, whose Category select is the move.
        delete:
          type: boolean
          description: Needs `can_manage_items`. The one management flag here that
            DOES have an endpoint — `DELETE /libraries/{library_id}/items/{id}`.
    LibraryDetailMeta:
      type: object
      description: Facts about THIS RESPONSE, as opposed to about the library. The
        counts here are response-scoped and narrow with `?category_id=`; their same-named
        twins on `library` are the library's true totals and do not. Print whichever
        the surface is actually describing — and never assume the two agree, because
        under a category scope they deliberately do not.
      required:
      - categories_count
      - items_count
      - categories_truncated
      properties:
        categories_count:
          type: integer
          description: The number of categories this response carries — so a client
            never prints a total the payload does not contain. Narrows with `?category_id=`.
            The library's own total is `library.categories_count`.
        items_count:
          type: integer
          description: |-
            The number of items this response carries, across every category. Response-scoped on the same terms; the library's own total is `library.items_count`.
            Counted per category at no more than `MAX_ITEMS_PER_CATEGORY` (500), so it can never exceed the number of item objects actually serialized even when a category is truncated. Where a category's own `items_count` is its TRUE total and may run ahead of its `items` array, this one deliberately does not — the two answer different questions. No library on any tenant reaches the cap today, so the distinction is dormant.
        categories_truncated:
          type: boolean
          description: |-
            True when the library holds more categories than this response carries — `categories` is capped at 100 (`MAX_CATEGORIES`), and this is what lets a client tell "this library has 100 categories" from "I was given the first 100 of more".
            Never silently partial: the same contract as `items_truncated` on each category and as `libraries_truncated` / `categories_truncated` on `GET /libraries/search`. The cap is 33× the largest library measured on any tenant (3 categories), so it truncates nothing that exists today — read the flag rather than the measurement.
          example: false
        scoped_to_category_id:
          type:
          - integer
          - 'null'
          description: Echoes `?category_id=` when it was applied, null otherwise
            — so a client can tell "this library has one category" from "I asked for
            one category".
    LibraryUserRef:
      type:
      - object
      - 'null'
      description: A minimal person reference for a byline.
      properties:
        id:
          type: integer
        name:
          type: string
          example: Neha Kulkarni
    LibrarySearchCounts:
      type: object
      description: |-
        Totals per record type, counted over the WHOLE match set rather than the returned page — so a client can label its groups ("Items 17") without pagination making the label lie.
        PRESENT AND COMPLETE ON EVERY PAGE, computed off the match relations rather than off the arrays beside it. That is what makes it the answer to "is `libraries: []` on page 2 an empty result, or a list I already have?" — and it is also what `libraries_truncated` / `categories_truncated` are derived from. Their sum is `counts_total`.
      required:
      - libraries
      - categories
      - items
      properties:
        libraries:
          type: integer
          example: 1
        categories:
          type: integer
          example: 1
        items:
          type: integer
          example: 16
    LibrarySearchItemPagination:
      type: object
      description: Describes the ITEM page — the only paginated list in this response.
        A dedicated schema rather than the shared ResponseMeta, which is the shape
        of the piggybacked `_meta` envelope (request_id / generated_at / execution_time_ms)
        and none of whose fields appear here. Same arrangement as the two sibling
        endpoints (LibraryListMeta, LibraryBookmarkPagination).
      required:
      - total_count
      - current_page
      - total_pages
      - per_page
      - has_next_page
      - has_prev_page
      properties:
        total_count:
          type: integer
          description: Matching ITEMS across every page — NOT the response's `counts_total`,
            which is every match of all three record types.
          example: 16
        current_page:
          type: integer
          example: 1
        total_pages:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          description: "`current_page < total_pages`. Page until this is false; do
            not infer the end from a short page."
          example: false
        has_prev_page:
          type: boolean
          example: false
    LibrarySearchCategory:
      type: object
      description: 'One matching category. Categories have no card elsewhere in this
        namespace (the detail endpoint nests them inside a library, with their items),
        so this is the shape a RESULT needs: enough to draw the row and to open the
        library scoped to that category.'
      required:
      - id
      - name
      - icon
      - search_score
      - library
      - link
      properties:
        id:
          type: integer
          example: 42
        name:
          type: string
          example: People
        icon:
          type: string
          description: The category's own Font Awesome class, falling back to `fas
            fa-folder` — the same fallback the web block and the detail endpoint apply.
          example: fas fa-folder
        search_score:
          type: integer
          example: 6
        library:
          type: object
          description: The library this category belongs to, for the row's path line.
          required:
          - id
          - name
          - color
          properties:
            id:
              type: integer
              example: 8
            name:
              type: string
              example: Company Policies
            color:
              type: string
              description: The library's mark colour, with the web card's fallback
                applied.
              example: "#2f64b1"
        link:
          type: string
          format: uri
          description: ABSOLUTE URL to the library page, anchored at this category's
            block — the same destination the web result row links to. Absolute because
            a native client cannot resolve a host-relative path.
          example: https://acme.workforce.mangoapps.com/apps/libraries/spaces/8#category-42
    LiveAssistSession:
      type: object
      required:
      - id
      - mode
      - status
      - provider
      - sessionable
      - host
      - recording_status
      - ai_summary_status
      - ai_summary
      - started_at
      - ended_at
      - duration_seconds
      - created_at
      properties:
        id:
          type: integer
        mode:
          type: string
          enum:
          - call_for_help
          - show_me
          - watch
          - huddle
        mode_label:
          type: string
          description: What the web app calls the mode — Help call, Live video, Huddle
            or Site feed; Remote support for a call a Service Desk agent placed to
            a ticket's requester (its `mode` is the help mode it rides on).
          example: Remote support
        status:
          type: string
          enum:
          - pending
          - live
          - ended
        status_label:
          type: string
          description: What the web app shows for the status — Not connected (pending),
            Live or Ended.
          example: Live
        provider:
          type: string
          enum:
          - livekit
        sessionable:
          type: object
          description: The work object the session is stapled to.
          required:
          - type
          - id
          properties:
            type:
              type: string
              description: Polymorphic class name.
              example: FieldService::WorkOrder
            id:
              type: integer
            name:
              type: string
              nullable: true
              description: The record's own name (a work order's title, an inspection's
                name); null when it has none or is gone.
            label:
              type: string
              description: The label the web lists print — the record's name and its
                reference.
              example: Pump 3 repair — Work Order WO-00012
        answered_by:
          type: object
          nullable: true
          description: The responder who has answered a live help call ("… is on it");
            null when nobody has, the mode is not answered, or the session has ended.
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
              description: The responder's name as every web surface prints it (full
                name, else display name); their email where the directory shows emails,
                else "Unnamed user". Never null.
        host:
          type: object
          nullable: true
          description: Who started the session; null when it has no host.
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
              description: The host's name as every web surface prints it (full name,
                else display name); their email where the directory shows emails,
                else "Unnamed user". Never null.
        recording_status:
          type: string
          enum:
          - none
          - recording
          - processing
          - ready
          - failed
        ai_summary_status:
          type: string
          enum:
          - none
          - processing
          - ready
          - failed
        ai_summary:
          type: string
          nullable: true
          description: The AI summary of the recording, once one has been written.
        started_at:
          type: string
          format: date-time
          nullable: true
          description: Null until the session goes live.
        ended_at:
          type: string
          format: date-time
          nullable: true
        duration_seconds:
          type: integer
          description: Seconds from `started_at` to `ended_at` (or to now while the
            session is live); 0 when it never started.
        created_at:
          type: string
          format: date-time
    LiveBoard:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
          - active
          - archived
        source_type:
          type: string
          description: Where the board reads from, e.g. `power_table_view` or `recognitions`.
        sample_data:
          type: boolean
          description: True for a board created by "Load sample data".
        visualizations_count:
          type: integer
          description: The number of readings IN `visualizations` — i.e. the readings
            this caller may see, not the board's total.
        visualizations:
          type: array
          items:
            "$ref": "#/components/schemas/LiveBoardReading"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    LiveBoardReading:
      type: object
      description: One reading (visualization) on a board.
      properties:
        id:
          type: integer
        name:
          type: string
        visualization:
          type: string
          description: One of ranked_list, stat, line, table, chart, movers, gauge,
            status, share, calendar, streak, pace, scorecard.
        position:
          type: integer
        source_name:
          type: string
          nullable: true
          description: The source table's name. Null for a native-source reading,
            and when the caller may not open the source table.
        data:
          "$ref": "#/components/schemas/LiveBoardReadingData"
    LiveBoardReadingData:
      type: object
      description: The reading's current values. Present on the board detail endpoint
        only. The members below are common to every visualization; the rest of the
        object varies by type (`entries` for ranked lists and status grids, `value`/`goal`
        for stats, gauges and pace, `points` for lines, and so on). Masking is already
        applied.
      properties:
        viz:
          type: string
        period_label:
          type: string
          nullable: true
        source_name:
          type: string
          nullable: true
        source_updated_at:
          type: string
          format: date-time
          nullable: true
        source_url:
          type: string
          nullable: true
          description: Drill-down into the source table. Present only when this caller
            could open it.
        data_version:
          type: integer
          nullable: true
        error:
          type: string
          description: Present INSTEAD of the values when the reading's source is
            unavailable or its configuration no longer matches it.
    LiveBoardPlaylist:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        status:
          type: string
          enum:
          - active
          - archived
        interval_seconds:
          type: integer
          description: Seconds each visualization is held on screen (15–600).
        visualization_ids:
          type: array
          description: The rotation's visualizations, in play order — the ids the
            rotation was authored with, including any since archived or restricted
            (`boards` on the detail endpoint omits those).
          items:
            type: integer
        board_ids:
          type: array
          deprecated: true
          description: 'Deprecated alias of `visualization_ids`: the same visualization
            ids (not board ids), kept for existing clients.'
          items:
            type: integer
        shared:
          type: boolean
          description: Whether sharing is switched on for this playlist. Says nothing
            about whether the link opens right now — see link_state.
        link_state:
          type: string
          enum:
          - live
          - 'off'
          - archived
          - public_links_off
          description: 'Whether the rotation link opens right now: live; off (sharing
            is switched off for this playlist); archived (the playlist is archived);
            public_links_off (public board links are switched off for the whole workspace).
            Says nothing about individual visualizations.'
        rotation_url:
          type: string
          nullable: true
          description: App admins only, and null when the rotation is not shared.
            An unauthenticated URL — treat it as a credential. Also returned for an
            archived playlist and while public links are off — check link_state.
        embed_allowed_origins:
          type: array
          description: App admins only. Sites permitted to frame the rotation. Empty
            means the rotation is framed only where every one of its visualizations
            allows it; if none of them restrict framing, any site may.
          items:
            type: string
        boards:
          type: array
          description: Detail endpoint only — the rotation's visualizations the caller
            may see, in play order (named `boards` for existing clients).
          items:
            "$ref": "#/components/schemas/LiveBoardReading"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    LocationDepartmentsDepartment:
      type: object
      required:
      - id
      - name
      - department_type
      - department_type_display
      - description
      - active
      properties:
        id:
          type: integer
        name:
          type: string
        department_type:
          type: string
          nullable: true
          description: The department's kind. The app offers floor, wing, unit, section,
            department, division, area, zone, team and support, but the column is
            not constrained to that list; null when unset.
          example: floor
        department_type_display:
          type: string
          description: '`department_type` titleized for display (e.g. "Floor"); "General"
            when `department_type` is null.'
          example: Floor
        description:
          type: string
          nullable: true
        active:
          type: boolean
          description: Always true here — inactive departments are not listed.
    AssignedLocation:
      type: object
      required:
      - id
      - name
      - enforce_geofence
      - is_primary
      properties:
        id:
          type: integer
        name:
          type: string
        address:
          type: string
          nullable: true
        latitude:
          type: number
          format: double
          nullable: true
        longitude:
          type: number
          format: double
          nullable: true
        enforce_geofence:
          type: boolean
          description: True when clock-ins at this location are checked against its
            geofence.
        allowed_radius_meters:
          type: integer
          nullable: true
          description: Geofence radius; null when the location has no geofence.
        is_primary:
          type: boolean
          description: True for the user's primary location (at most one in the list).
    LoneWorkerPerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
    LoneWorkerAlert:
      type: object
      properties:
        id:
          type: integer
        trigger_type:
          type: string
          enum:
          - sos
          - missed_check_in
          - geofence
          - no_checkout
          - line_jam
        trigger_note:
          type: string
          nullable: true
          description: The worker's own description of what is wrong (280 characters
            at most).
        trigger_event:
          type: string
          nullable: true
          description: The device event that raised the alert (wearables only, e.g.
            fall, man_down, panic). Null for an alert a person raised in the app.
        detection_label:
          type: string
          nullable: true
          description: '"Fall detected" or "Man-down detected" when a device detected
            distress on its own; null when a person pressed SOS.'
        unassigned_device:
          type: boolean
          description: True when a device with no badge (or an unknown one) raised
            it. The alert is then filed under the wearables integration's fallback
            person, who is not the one in distress.
        state:
          type: string
          enum:
          - raised
          - notifying
          - acknowledged
          - resolved
          - exhausted
        current_tier:
          type: integer
        worker:
          "$ref": "#/components/schemas/LoneWorkerPerson"
        shift_id:
          type: integer
          nullable: true
        roster:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            scope:
              type: string
        location:
          type: object
          nullable: true
          properties:
            lat:
              type: number
            lng:
              type: number
            accuracy_m:
              type: number
              nullable: true
        raised_at:
          type: string
          format: date-time
          nullable: true
        acknowledged_by:
          allOf:
          - "$ref": "#/components/schemas/LoneWorkerPerson"
          nullable: true
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        ack_resumes_at:
          type: string
          format: date-time
          nullable: true
          description: When an acknowledgement with no outcome recorded goes back
            to its tier and pages it again (never while Lone Worker is switched off);
            null unless acknowledged, while the app is switched off, and for sample
            alerts.
        exhaustion_kind:
          type: string
          nullable: true
          enum:
          - unanswered
          - lapsed
          - abandoned
          - handed_back
          - unstaffed
          -
          description: 'Why an exhausted alert ran out, on GET /alerts/{id} (and safety_status.exhausted_alert):
            nobody acknowledged, an acknowledgement lapsed, was left open, was handed
            back, or nobody could be alerted. Omitted from lists.'
        resolution:
          type: string
          nullable: true
          enum:
          - safe
          - false_alarm
          - incident
          -
        resolved_at:
          type: string
          format: date-time
          nullable: true
        resolution_note:
          type: string
          nullable: true
        incident_id:
          type: integer
          nullable: true
        is_sample_data:
          type: boolean
          description: True for a seeded demo alert — render it as sample data
          never as a real emergency.:
    LoneWorkerAlertEvent:
      type: object
      properties:
        event_type:
          type: string
          enum:
          - raised
          - tier_notified
          - notification_sent
          - acknowledged
          - released
          - resolved
          - exhausted
          - cancelled
          - recovered
          - incident_handoff_failed
          - check_in_missed
        label:
          type: string
          description: The row in words, as the web trail shows it ("Handed back",
            "Acknowledgement lapsed — back on its tier", "Check-in missed during this
            alert"). Show this, not `event_type`.
        occurred_at:
          type: string
          format: date-time
          nullable: true
        tier:
          type: integer
          nullable: true
          description: The ladder rung. Roster tiers start at 1; 0 is the break-glass
            rung, the safety admins paged when no roster covered the alert or nobody
            on it could be paged.
        channel:
          type: string
          nullable: true
        actor:
          allOf:
          - "$ref": "#/components/schemas/LoneWorkerPerson"
          nullable: true
        recipient:
          allOf:
          - "$ref": "#/components/schemas/LoneWorkerPerson"
          nullable: true
          description: Who a `notification_sent` row paged.
        via:
          type: string
          nullable: true
          description: The door an acknowledgement came through (web, api, agent,
            inbox, voice), or `device` on a `raised` row a wearable raised.
        delivery:
          type: string
          nullable: true
          description: "`notification_sent` only — `requested`, `skipped` or `error`."
    LoneWorkerAlertDetail:
      allOf:
      - "$ref": "#/components/schemas/LoneWorkerAlert"
      - type: object
        properties:
          resolved_by:
            allOf:
            - "$ref": "#/components/schemas/LoneWorkerPerson"
            nullable: true
          events:
            type: array
            maxItems: 50
            description: The newest trail rows, oldest first.
            items:
              "$ref": "#/components/schemas/LoneWorkerAlertEvent"
          events_total:
            type: integer
          events_truncated:
            type: boolean
            description: True when the alert has more trail rows than `events` carries.
          safety_admin_fallback:
            type: string
            nullable: true
            enum:
            - no_roster
            - nobody_pageable
            -
            description: 'Set when the safety admins were paged instead of a roster''s
              responders: `no_roster` (no roster covered the shift when the alert
              was raised) or `nobody_pageable` (a roster resolved but nobody on it
              could be paged). Null otherwise.'
    LoneWorkerCheckIn:
      type: object
      properties:
        id:
          type: integer
        state:
          type: string
          enum:
          - armed
          - ok
          - missed
          - cancelled
        due_at:
          type: string
          format: date-time
          nullable: true
        responded_at:
          type: string
          format: date-time
          nullable: true
        shift_id:
          type: integer
          nullable: true
    LoneWorkerRaiseResult:
      type: object
      properties:
        alert:
          "$ref": "#/components/schemas/LoneWorkerAlert"
        reached_responders:
          type: boolean
          description: Absent on the `already_active` reply. False means nobody was
            paged.
        already_active:
          type: boolean
          description: Present (true) only when an SOS was already in progress.
        message:
          type: string
          description: Present only when no responder could be reached.
    MangoSignalAction:
      type: object
      description: An action. `scope_type`, `segment_key`, `scope_id` (null with a
        `scope_label` for an action about one individual) and `signal_ids` appear
        only for a manager-tier caller; `description`, `completion_notes`, `source_survey`,
        `signal_ids` and `impact` only on a single action (GET or POST/PATCH by id).
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - open
          - in_progress
          - complete
          - dismissed
        assignee_id:
          type: integer
          nullable: true
        assignee_name:
          type: string
          nullable: true
        assignee_active:
          type: boolean
        owner_id:
          type: integer
        owner_name:
          type: string
          nullable: true
        owner_active:
          type: boolean
        due_at:
          type: string
          format: date-time
          nullable: true
          description: The due day, stored as noon UTC of that day.
        due_date:
          type: string
          format: date
          nullable: true
          description: The day it is due (`YYYY-MM-DD`) — the same day in every time
            zone.
        completed_at:
          type: string
          format: date-time
          nullable: true
        ai_recommended:
          type: boolean
        created_at:
          type: string
          format: date-time
        is_sample_data:
          type: boolean
        description:
          type: string
          nullable: true
        completion_notes:
          type: string
          nullable: true
        source_survey:
          type: object
          nullable: true
        impact:
          type: object
          nullable: true
        scope_type:
          type: string
          nullable: true
        segment_key:
          type: string
          nullable: true
        scope_id:
          type: integer
          nullable: true
        scope_label:
          type: string
        signal_ids:
          type: array
          items:
            type: integer
        warnings:
          type: array
          items:
            type: string
          description: Present only when a submitted `scope_id` was dropped.
    MangoSignalRefusal:
      type: object
      required:
      - error
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              example: action_access_required
            message:
              type: string
        code:
          type: string
        message:
          type: string
        field:
          type: string
        status:
          type: string
        valid_statuses:
          type: array
          items:
            type: string
    MangoSpendExpenseReport:
      type: object
      description: An expense report. `purpose`, `lines`, `violations` and `standing`
        appear only in the detailed form — the answers of create, show, submit, approve,
        reject and request_changes; the lists (`expense_reports`, `approvals`) and
        the line-removal answer carry the short form.
      properties:
        id:
          type: integer
        number:
          type: string
        title:
          type: string
        status:
          type: string
          enum:
          - draft
          - submitted
          - approved
          - rejected
          - sent_back
          - paid
          - cancelled
          description: '`sent_back` is shown to people as "Changes Requested".'
        status_label:
          type: string
          description: The status as every page and email words it ("Changes Requested"
            for `sent_back`).
        currency:
          type: string
        total_cents:
          type: integer
        reimbursable_cents:
          type: integer
        submitted_at:
          type: string
          format: date-time
          nullable: true
        decided_at:
          type: string
          format: date-time
          nullable: true
        paid_at:
          type: string
          format: date-time
          nullable: true
        url:
          type: string
          nullable: true
          description: 'The web page to send the caller to for this report (a path
            on the tenant''s host): the reimbursements page for the owner, their manager
            and Mango Spend admins, the approval review page for an approver named
            only in the report''s chain.'
        purpose:
          type: string
          nullable: true
        lines:
          type: array
          items:
            "$ref": "#/components/schemas/MangoSpendExpenseLine"
        violations:
          type: array
          items:
            "$ref": "#/components/schemas/MangoSpendPolicyViolation"
        standing:
          type: object
          description: Where the report stands, as its page says it. Each key appears
            only when it applies.
          properties:
            decision:
              type: object
              description: The last decision on an approved, rejected, sent-back or
                paid report.
              properties:
                kind:
                  type: string
                  enum:
                  - approved
                  - rejected
                  - sent_back
                label:
                  type: string
                  description: '"Approved", "Rejected" or "Changes Requested".'
                decided_at:
                  type: string
                  format: date-time
                decided_by:
                  type: string
                  description: The approver's name. Absent when they have none on
                    file.
                decision_note:
                  type: string
                  description: The approver's reason
                  in their words.:
            waiting_on:
              type: object
              description: Who a submitted report is waiting on.
              properties:
                names:
                  type: array
                  items:
                    type: string
                  description: The approver, or the Mango Spend admins it went to
                    (up to three).
                nobody:
                  type: boolean
                  description: True when no one can review it yet.
                sentence:
                  type: string
            payment:
              type: object
              description: For an approved or paid report — its payment batch and
                pay date.
              properties:
                batch_number:
                  type: string
                batch_status:
                  type: string
                  enum:
                  - open
                  - exported
                  - sent_to_payroll
                  - paid
                  - failed
                  - voided
                  - cancelled
                batch_status_label:
                  type: string
                  description: The batch's state as the Payments page words it.
                batch_url:
                  type: string
                  description: The batch's page — only for a caller who can run payments.
                pay_date:
                  type: string
                  format: date
                  description: The pay date the batch's payment file carries.
                paid_at:
                  type: string
                  format: date-time
                sentence:
                  type: string
                  description: The report page's sentence for this state.
    MangoSpendExpenseLine:
      type: object
      properties:
        id:
          type: integer
        line_type:
          type: string
          enum:
          - expense
          - mileage
          - per_diem
        category:
          type: string
          nullable: true
          description: The category's name.
        description:
          type: string
          nullable: true
        vendor:
          type: string
          nullable: true
        spent_on:
          type: string
          format: date
          nullable: true
        amount_cents:
          type: integer
        tax_cents:
          type: integer
        miles:
          type: number
        currency:
          type: string
          description: The report's currency.
        needs_amount:
          type: boolean
          description: The line has no money on it yet (amount of zero). Submitting
            is refused until it is priced.
        needs_amount_reason:
          type: string
          nullable: true
          description: Why the line has no amount and who fixes it — the sentence
            its row on the report page shows (for example, no mileage rate covers
            its date). Null when the line has an amount.
        receipts:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              status:
                type: string
                description: The receipt reader's progress with the file.
    MangoSpendLineInput:
      type: object
      description: The fields a client may set on a line; any other key is ignored.
        `currency` and the mileage and per-diem rates are not among them — the line
        takes the report's currency and the business's rates.
      properties:
        line_type:
          type: string
          enum:
          - expense
          - mileage
          - per_diem
          description: "`mileage` needs `miles` above zero; `per_diem` needs `days`
            above zero and a `location_code`. The line's own rules apply on top (see
            the 422 `invalid_line` message)."
        category_id:
          type: integer
          nullable: true
          description: A spend category of the business; an archived one is refused.
        description:
          type: string
          maxLength: 2000
          nullable: true
        vendor:
          type: string
          maxLength: 500
          nullable: true
        spent_on:
          type: string
          format: date
          description: Required. For an expense line, not more than a day in the future.
        amount_cents:
          type: integer
          minimum: 0
          maximum: 99999999999999
          description: Required above zero for an expense line.
        tax_cents:
          type: integer
          minimum: 0
          maximum: 99999999999999
        miles:
          type: number
          minimum: 0
          description: Required above zero for a mileage line.
        days:
          type: number
          description: Required above zero for a per-diem line (at most 9999.99).
        location_code:
          type: string
          description: Required for a per-diem line.
        cost_center:
          type: string
          maxLength: 500
          nullable: true
        project_code:
          type: string
          maxLength: 500
          nullable: true
        client_reference:
          type: string
          maxLength: 500
          nullable: true
        billable_to_client:
          type: boolean
          description: A null or blank value is treated as not sent.
        payment_method:
          type: string
          nullable: true
          description: How it was paid (for example cash, personal_card, corporate_card
            or other).
    MangoSpendPolicyViolation:
      type: object
      description: A spend-policy finding on a report or one of its lines.
      properties:
        line_id:
          type: integer
          nullable: true
        code:
          type: string
          description: Stable identifier, for example `amount_missing` or `missing_receipt`.
        severity:
          type: string
          enum:
          - block
          - warn
          description: A `block` finding stops the report being submitted.
        message:
          type: string
        metadata:
          type: object
    MangoSpendPaginationMeta:
      type: object
      description: The API's standard pagination object, as `build_pagination_meta`
        answers it (the shared PaginationMeta schema lacks the two `has_*` flags this
        endpoint sends).
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      - has_next_page
      - has_prev_page
      properties:
        total_count:
          type: integer
          description: Rows in the whole filtered set.
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    MangoSpendSummary:
      type: object
      description: The caller's roll-up. The first block is always present; the approver
        and company blocks appear only for a caller who holds that role AND a token
        with the wide `read:mango_spend` grant (or a session). Money is in minor units;
        the `*_totals` objects hold cents per currency code (zero currencies left
        out) and are the figure to show a multi-currency business — the single `*_cents`
        sums add currencies together.
      required:
      - currency
      - drafts
      - in_flight
      - paid_ytd_cents
      - changes_requested
      - returned_for_changes
      - to_finish
      - in_flight_totals
      - awaiting_payment_totals
      - paid_ytd_totals
      properties:
        currency:
          type: string
          description: The business's default currency.
        drafts:
          type: integer
          description: The caller's draft reports.
        in_flight:
          type: integer
          description: 'The caller''s reports on their way to being paid: submitted
            (awaiting a decision) plus approved (decided, not yet paid).'
        paid_ytd_cents:
          type: integer
          description: The reimbursable cents of the caller's own reports paid since
            January 1, summed across currencies into one figure (see `paid_ytd_totals`
            for the per-currency split).
        changes_requested:
          type: integer
          description: Claims an approver returned for changes.
        returned_for_changes:
          type: integer
          description: The same count as `changes_requested`.
        to_finish:
          type: integer
          description: Drafts plus returned claims — the caller's to finish.
        in_flight_totals:
          type: object
          additionalProperties:
            type: integer
        awaiting_payment_totals:
          type: object
          additionalProperties:
            type: integer
        paid_ytd_totals:
          type: object
          additionalProperties:
            type: integer
        awaiting_approval:
          type: integer
          description: Approver only — reports in the caller's approval queue (what
            `GET /mango_spend/approvals` lists).
        awaiting_approval_cents:
          type: integer
          description: Approver only.
        awaiting_approval_totals:
          type: object
          additionalProperties:
            type: integer
          description: Approver only.
        unpaid_approved_count:
          type: integer
          description: Callers who may view all spend only — approved reports in no
            payment batch.
        unpaid_approved_cents:
          type: integer
          description: Company block only.
        unpaid_approved_totals:
          type: object
          additionalProperties:
            type: integer
          description: Company block only.
        owed_approved_count:
          type: integer
          description: Company block only — every approved report not yet paid, in
            a batch or not.
        owed_approved_cents:
          type: integer
          description: Company block only.
        owed_approved_totals:
          type: object
          additionalProperties:
            type: integer
          description: Company block only.
    MealComplianceBreakComplianceTile:
      type: object
      required:
      - key
      - label
      - value
      - caption
      - icon
      - tone
      properties:
        key:
          type: string
          enum:
          - findings
          - unresolved
          - employees_affected
          - by_period
        label:
          type: string
        value:
          type: integer
        caption:
          type: string
        hint:
          type: string
          description: by_period only — the explanation of Unbroken work.
        icon:
          type: string
          description: A Font Awesome icon name.
        tone:
          type: string
          enum:
          - primary
          - danger
          - info
          - warning
        filter:
          type: object
          nullable: true
          description: unresolved only — the query that opens what it counted; null
            at zero.
          properties:
            status:
              type: string
              enum:
              - open
        parts:
          type: array
          description: by_period only — Meal, Rest and Unbroken work, which add up
            to Findings.
          items:
            type: object
            properties:
              period_kind:
                type: string
                enum:
                - meal
                - rest
                - any
              label:
                type: string
              count:
                type: integer
              selected:
                type: boolean
              filter:
                type: object
                nullable: true
                description: The query that opens this part's findings; null at zero.
                properties:
                  period_kind:
                    type: string
                    enum:
                    - meal
                    - rest
                    - any
    MealComplianceBreakComplianceReport:
      type: object
      properties:
        applied_filters:
          type: object
          description: The filters IN FORCE (an unrecognised value is not one).
          properties:
            status:
              type: string
              enum:
              - all
              - open
              - resolved
            event_type:
              type: string
              nullable: true
            period_kind:
              type: string
              nullable: true
            from:
              type: string
              format: date
              nullable: true
            to:
              type: string
              format: date
              nullable: true
            employee:
              type: object
              nullable: true
              description: The employee the list is narrowed to — draw "Showing findings
                for <name> only".
              properties:
                id:
                  type: integer
                name:
                  type: string
            employee_unknown:
              type: boolean
              description: A user_id that names nobody in this organization.
            active:
              type: boolean
              description: Whether any filter narrows the report.
        enforcement:
          type: object
          description: What the organization's break enforcement does to people today.
          properties:
            mode:
              type: string
              enum:
              - observe
              - enforce
              - 'off'
            label:
              type: string
            description:
              type: string
            icon:
              type: string
            tone:
              type: string
              enum:
              - info
              - warning
            can_change:
              type: boolean
              description: A business admin
              who may change it on the web (Work Policy Settings).:
        tiles:
          type: array
          items:
            "$ref": "#/components/schemas/MealComplianceBreakComplianceTile"
        rules_in_force:
          type: object
          properties:
            state:
              type: string
              enum:
              - listed
              - none
              - unavailable
            total:
              type: integer
              nullable: true
              description: Every rule in force; items holds at most 25.
            note:
              type: string
              nullable: true
              description: What none or unavailable means.
            items:
              type: array
              items:
                type: object
                properties:
                  rule_id:
                    type: integer
                  summary:
                    type: string
                  location_count:
                    type: integer
                  locations:
                    type: array
                    description: At most 8; more_locations_count says how many more.
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                  more_locations_count:
                    type: integer
            can_manage_rules:
              type: boolean
              description: A business admin
              who manages Labor Law Rules on the web.:
        filters:
          type: array
          description: The status chips, counted under every other filter.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - all
                - open
                - resolved
              label:
                type: string
              count:
                type: integer
              selected:
                type: boolean
        options:
          type: object
          description: The filter sheet's choices, in the report's words.
          properties:
            event_types:
              type: array
              items:
                type: object
                properties:
                  key:
                    type: string
                  label:
                    type: string
            period_kinds:
              type: array
              items:
                type: object
                properties:
                  key:
                    type: string
                  label:
                    type: string
        items:
          type: array
          description: One page of findings, newest work date first.
          items:
            "$ref": "#/components/schemas/MealComplianceBreakFinding"
        empty_state:
          type: object
          nullable: true
          description: Present when no finding matches.
          properties:
            kind:
              type: string
              enum:
              - rules_unavailable
              - no_rules
              - filtered
              - none_recorded
            title:
              type: string
            body:
              type: string
            can_clear_filters:
              type: boolean
        meta:
          type: object
          properties:
            total_count:
              type: integer
            total_pages:
              type: integer
            current_page:
              type: integer
            per_page:
              type: integer
            has_next_page:
              type: boolean
            has_prev_page:
              type: boolean
            filter_ignored:
              type: boolean
              description: Present when a filter value was not recognised.
            ignored_filters:
              type: array
              items:
                type: string
                enum:
                - status
                - event_type
                - period_kind
                - from
                - to
            filter_note:
              type: string
        permissions:
          type: object
          properties:
            can_resolve:
              type: boolean
              description: The token may Resolve a finding (POST /meal_compliance/break_compliance/{id}/resolve).
            can_change_enforcement_mode:
              type: boolean
            can_manage_rules:
              type: boolean
    MealComplianceBreakFinding:
      type: object
      description: One break-law finding, as the Break Compliance report lists it
        (GET /meal_compliance/break_compliance `items[]`) and as Resolve answers with
        it.
      required:
      - id
      - work_date
      - employee
      - finding
      - period
      - status
      - status_label
      properties:
        id:
          type: integer
        work_date:
          type: string
          format: date
          nullable: true
        employee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            initials:
              type: string
            avatar_url:
              type: string
              format: uri
        finding:
          type: object
          properties:
            type:
              type: string
              enum:
              - too_early
              - too_late
              - missing
              - short
              - second_meal_missing
              - unbroken_interval_exceeded
            label:
              type: string
              description: e.g. "Missing", "Started too late", "Unbroken stretch"
            tone:
              type: string
              enum:
              - warning
              - danger
        period:
          type: object
          properties:
            kind:
              type: string
              enum:
              - meal
              - rest
              - any
            ordinal:
              type: integer
            label:
              type: string
              description: e.g. "1st meal period", "Rest break", "Unbroken work"
        description:
          type: string
          description: The finding as a sentence.
        detail:
          type: string
          description: The finding's own words with its times and counts (and whether
            the employee reported it); the description when it has none.
        severity:
          type: string
          enum:
          - violation
          - warning
        mode:
          type: object
          description: The enforcement mode in force when the finding was detected.
          properties:
            key:
              type: string
              enum:
              - observe
              - enforce
            label:
              type: string
            tone:
              type: string
              enum:
              - neutral
              - dark
        status:
          type: string
          enum:
          - open
          - resolved
        status_label:
          type: string
          description: '"Unresolved" or "Resolved"'
        resolution:
          type: object
          nullable: true
          description: Present once resolved.
          properties:
            resolved_at:
              type: string
              format: date-time
            resolved_by:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
            note:
              type: string
              nullable: true
              description: The resolution note
              when one was given.:
        attendance_record_id:
          type: integer
          nullable: true
        detected_at:
          type: string
          format: date-time
          nullable: true
    MealComplianceDashboardPerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        initials:
          type: string
        avatar_url:
          type: string
          description: Absolute URL; a generated initials tile when the person has
            no photo.
    MealComplianceDashboard:
      type: object
      required:
      - scope
      - tiles
      - withdrawals
      - recent_activity
      - behavior_changes
      - permissions
      properties:
        scope:
          type: string
          enum:
          - all
          - team
          - self
          description: Whose records every figure covers — the whole organization,
            the caller's direct reports and themself, or the caller alone.
        tiles:
          type: array
          description: 'Always the four tiles, in this order: active_waivers, pending_signatures,
            behavior_alerts, invalidated. Labels and captions are the web Dashboard''s
            words.'
          items:
            type: object
            required:
            - key
            - label
            - value
            - caption
            - icon
            - tone
            properties:
              key:
                type: string
                enum:
                - active_waivers
                - pending_signatures
                - behavior_alerts
                - invalidated
              label:
                type: string
              value:
                type: integer
              caption:
                type: string
              icon:
                type: string
                description: A Font Awesome icon name (file-signature, clock, bell,
                  triangle-exclamation).
              tone:
                type: string
                enum:
                - success
                - warning
                - info
                - danger
              coverage:
                type: object
                description: active_waivers only. Of the employees whose logged breaks
                  show them waiving a meal in the last 30 days, how many have a waiver
                  in force that waives a meal period.
                properties:
                  percentage:
                    type: integer
                    nullable: true
                    description: Null when nobody in view is waiving.
                  waiving_employees:
                    type: integer
                  covered:
                    type: integer
                  not_covered:
                    type: integer
                  not_covered_label:
                    type: string
                    nullable: true
                    description: '"See N not covered" when permissions.can_view_gaps
                      (a link to the compliance-gaps report), "N not covered" otherwise;
                      null when everyone is covered.'
              awaiting_employee:
                type: integer
                description: pending_signatures only — out for signature
                waiting on the employee.:
              waiting_on_manager:
                type: integer
                description: 'pending_signatures only — never sent, its signing request
                  or link lapsed, or its term ended: a manager has to act.'
              window_days:
                type: integer
                description: behavior_alerts only — the window
                in days.:
        withdrawals:
          type: object
          description: Employees in view who withdrew their waiver, or declined to
            sign one, in the last `window_days` days and for whom no new waiver has
            been started since. Never the caller's own. The 10 most recent, with the
            uncapped `total`.
          properties:
            total:
              type: integer
            window_days:
              type: integer
            items:
              type: array
              items:
                type: object
                properties:
                  waiver_id:
                    type: integer
                  kind:
                    type: string
                    enum:
                    - withdrawn
                    - declined
                  description:
                    type: string
                  ended_at:
                    type: string
                    format: date-time
                  employee:
                    "$ref": "#/components/schemas/MealComplianceDashboardPerson"
        recent_activity:
          type: object
          description: The most recently updated waivers, newest first.
          properties:
            limit:
              type: integer
            items:
              type: array
              items:
                type: object
                properties:
                  waiver_id:
                    type: integer
                  status:
                    type: string
                    enum:
                    - draft
                    - pending_signature
                    - active
                    - not_yet_in_effect
                    - expired
                    - invalidated
                    description: The status as the waiver's badge reads it (in force
                      today, not the stored column).
                  status_label:
                    type: string
                    description: The badge's words — e.g. "Pending — needs sending",
                      "Withdrawn", "Declined", "Not yet in effect".
                  tone:
                    type: string
                    nullable: true
                    enum:
                    - success
                    - warning
                    - danger
                    - neutral
                    - info
                    - primary
                  location:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      name:
                        type: string
                  updated_at:
                    type: string
                    format: date-time
                  employee:
                    "$ref": "#/components/schemas/MealComplianceDashboardPerson"
        behavior_changes:
          type: object
          description: 'Detected changes in break behavior in the last `window_days`
            days, newest first: the 10 most recent, with the uncapped `total` (the
            Behavior alerts tile''s value).'
          properties:
            total:
              type: integer
            window_days:
              type: integer
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  previous_pattern:
                    type: string
                    enum:
                    - waiving
                    - taking
                    - inconsistent
                  previous_pattern_label:
                    type: string
                  detected_pattern:
                    type: string
                    enum:
                    - waiving
                    - taking
                    - inconsistent
                  detected_pattern_label:
                    type: string
                  summary:
                    type: string
                    example: Changed from Taking to Waiving
                  detected_at:
                    type: string
                    format: date-time
                  employee:
                    "$ref": "#/components/schemas/MealComplianceDashboardPerson"
        permissions:
          type: object
          description: What the caller's role and token allow from this screen.
          properties:
            can_create_waiver:
              type: boolean
              description: The + (New Waiver) button.
            can_view_waivers:
              type: boolean
              description: View All — the waiver register.
            can_view_gaps:
              type: boolean
              description: The not-covered link to the compliance-gaps report.
            gaps_web_url:
              type: string
              format: uri
              nullable: true
              description: 'Where "See N not covered" opens: the web compliance-gaps
                report (it has no API endpoint), for the caller signed in on the web.
                Present exactly when `can_view_gaps`; null otherwise.'
    MealComplianceMyBreaksWaiver:
      type: object
      description: The fields every waiver in this response carries.
      required:
      - id
      - status
      - status_label
      - behavior_state
      - elections
      - details_path
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - draft
          - pending_signature
          - active
          - not_yet_in_effect
          - expired
          - invalidated
          description: The status as the waiver's badge reads it (in force today,
            not the stored column).
        status_label:
          type: string
          description: The badge's words — e.g. "Active", "Not yet in effect", "Pending
            — link expired", "Withdrawn", "Declined".
        tone:
          type: string
          nullable: true
          enum:
          - success
          - warning
          - danger
          - neutral
          - info
          - primary
          description: The badge's colour, the same tone the web register uses.
        behavior_state:
          type: string
          enum:
          - waiving
          - taking
        elections:
          type: array
          description: Per meal period when the agreement records them; empty for
            a single election.
          items:
            type: object
            properties:
              period_ordinal:
                type: integer
              label:
                type: string
                example: 1st meal period
              waived:
                type: boolean
              choice_label:
                type: string
                example: Waived
        elections_summary:
          type: string
          description: The choice in one line — the per-period elections, else "Waiving
            meal breaks" / "Taking meal breaks".
        jurisdiction_name:
          type: string
          nullable: true
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        valid_from:
          type: string
          format: date
          nullable: true
        valid_until:
          type: object
          description: The end of the term, in the Waivers register's shape.
          properties:
            date:
              type: string
              format: date
              nullable: true
              description: Null means no end date.
            label:
              type: string
              nullable: true
              description: '"No expiry" when there is no end date.'
            days_until_expiry:
              type: integer
              nullable: true
              description: Negative once the term has passed.
            tag:
              type: object
              nullable: true
              description: The register's tag beside the date — `expiring` when in
                force and inside the expiry-reminder window, `expired` for a draft
                or pending waiver past its term.
              properties:
                kind:
                  type: string
                  enum:
                  - expiring
                  - expired
                label:
                  type: string
                tone:
                  type: string
        notes:
          type: string
          nullable: true
          description: The manager's note
          printed on the employee's copy.:
        is_sample_data:
          type: boolean
        updated_at:
          type: string
          format: date-time
        signed_copy:
          type: object
          nullable: true
          description: The caller's own signed copy, only where E-Signature would
            let them download it.
          properties:
            esignature_request_id:
              type: integer
              description: GET /esignature/{id} answers a time-limited signed_document_url.
            download_path:
              type: string
              description: The web download path (session-authenticated).
        details_path:
          type: string
          description: The web waiver page
          which admits its own employee.:
    MealComplianceMyBreaks:
      type: object
      required:
      - title
      - intro
      - pending_count
      - break_pattern
      - waiver_history
      - drafts
      properties:
        title:
          type: string
        intro:
          type: string
        pending_waiver:
          nullable: true
          allOf:
          - "$ref": "#/components/schemas/MealComplianceMyBreaksWaiver"
          - type: object
            properties:
              heading:
                type: string
              terms:
                type: string
                nullable: true
                description: What signing records. Null once signing is closed for
                  the caller.
              signing:
                type: object
                properties:
                  state:
                    type: string
                    nullable: true
                    enum:
                    - ready
                    - never_sent
                    - request_voided
                    - request_declined
                    - request_expired
                    - signing_complete
                    - not_a_signer
                    - you_signed
                    - signed_copy_failed
                    - you_declined
                    - link_expired
                    - awaiting_other_signers
                    - no_link
                    - signing_switched_off
                  can_sign:
                    type: boolean
                    description: The Review & Sign button.
                  sign_url:
                    type: string
                    nullable: true
                    description: The caller's own live signing link, when can_sign.
                  web_view_url:
                    type: string
                    nullable: true
                    description: 'The app''s Review & Sign sheet, when can_sign: the
                      waiver as the document they sign, read-only — the organization,
                      title, rows, agreement and notes, the same content its PDF is
                      made from — at `GET /m/apps/meal-compliance/{id}/document`,
                      a signed-in /m/ page with no app chrome, for the app''s web
                      view. The app draws the signature pad, consent, Sign Document
                      (`POST /meal_compliance/waivers/{id}/sign`) and Decline (`POST
                      …/decline`).'
                  can_sign_in_app:
                    type: boolean
                    description: The app's own Sign Document — false on a read-only
                      token, under an admin's View-as, for a sample waiver, or once
                      the waiver's term has ended.
                  sign:
                    type: object
                    nullable: true
                    description: What the app's Sign step asks for, when can_sign_in_app.
                    properties:
                      label:
                        type: string
                        example: Sign Document
                      signature_label:
                        type: string
                        example: Signature
                      signature_types:
                        type: array
                        items:
                          type: string
                          enum:
                          - typed
                          - drawn
                      signature_fonts:
                        type: array
                        items:
                          type: string
                          enum:
                          - cursive
                          - elegant
                          - formal
                      date_label:
                        type: string
                        example: Date
                      date:
                        type: string
                        format: date
                        description: Their own today — the default the Sign call uses
                          when it sends no date.
                      consent_label:
                        type: string
                        description: The consent the signer confirms (consent true
                          on Sign). It refers to the disclosure below, which the app
                          shows above it.
                        example: I have read the electronic records disclosure and
                          agree to sign electronically. My electronic signature is
                          legally binding.
                      disclosure:
                        type: object
                        description: The ESIGN electronic records disclosure the signer
                          is shown before consenting — the same wording as the emailed
                          signing page. Its version is recorded on the signed audit
                          row.
                        properties:
                          title:
                            type: string
                          text:
                            type: string
                            description: The paragraphs joined by blank lines.
                          paragraphs:
                            type: array
                            items:
                              type: string
                          version:
                            type: string
                            description: The disclosure version recorded as esign_disclosure_version.
                      hint:
                        type: string
                  note:
                    type: string
                    nullable: true
                    description: Why the caller cannot sign now; null when they can.
                  link_expires_at:
                    type: string
                    format: date-time
                    nullable: true
              can_decline:
                type: boolean
                description: Decline to Sign (POST /meal_compliance/waivers/{id}/decline).
              decline:
                type: object
                nullable: true
                description: The "Decline to Sign" sheet, when can_decline.
                properties:
                  label:
                    type: string
                  title:
                    type: string
                  message:
                    type: string
                  confirm_label:
                    type: string
                  reason_field:
                    type: object
                    properties:
                      label:
                        type: string
                      required:
                        type: boolean
                      max_length:
                        type: integer
              can_withdraw:
                type: boolean
                description: Withdraw before signing — only where can_decline is false.
              withdraw_confirm:
                type: string
                nullable: true
              sample_note:
                type: string
                nullable: true
                description: Why a sample waiver offers no Withdraw.
        pending_count:
          type: integer
          description: How many of the caller's waivers await signature.
        current_waiver:
          nullable: true
          allOf:
          - "$ref": "#/components/schemas/MealComplianceMyBreaksWaiver"
          - type: object
            properties:
              choice_label:
                type: string
                example: Waiving meal breaks
              in_force:
                type: boolean
                description: In force today (not starting later
                not past its term).:
              start_sentence:
                type: string
                nullable: true
              end_sentence:
                type: string
              expiry:
                type: object
                properties:
                  expiring_soon:
                    type: boolean
                    description: In force and inside the organization's expiry-reminder
                      window.
                  label:
                    type: string
                    nullable: true
                    example: Expires in 9 days
                  renewed:
                    type: boolean
                    description: A signed renewal already runs past this one's end.
                  renewed_note:
                    type: string
                    nullable: true
              can_revoke:
                type: boolean
                description: Revoke my waiver.
              revoke_label:
                type: string
                nullable: true
                description: '"Revoke my waiver"; "Withdraw this waiver" for one whose
                  start date is still ahead; "Withdraw my election" for an agreement
                  that waives no meal period.'
              revoke_explainer:
                type: string
                nullable: true
              revoke_confirm:
                type: string
                nullable: true
              sample_note:
                type: string
                nullable: true
                description: Why a sample waiver offers no Revoke.
        no_current_waiver:
          type: object
          nullable: true
          description: The empty state, present only when current_waiver is null.
          properties:
            title:
              type: string
            message:
              type: string
        break_pattern:
          type: object
          properties:
            total:
              type: integer
              description: Every detected observation on file (uncapped).
            limit:
              type: integer
              description: How many the list holds (10).
            truncation_note:
              type: string
              nullable: true
            empty_state:
              type: object
              nullable: true
              properties:
                title:
                  type: string
                message:
                  type: string
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  label:
                    type: string
                    description: 'The window and its verdict, e.g. "Oct 1 – Oct 15:
                      skipped 11 of 14 days".'
                  detected_pattern:
                    type: string
                    enum:
                    - waiving
                    - taking
                    - inconsistent
                  previous_pattern:
                    type: string
                    enum:
                    - waiving
                    - taking
                    - inconsistent
                    - unknown
                  window_start:
                    type: string
                    format: date
                    nullable: true
                  window_end:
                    type: string
                    format: date
                    nullable: true
                  detected_at:
                    type: string
                    format: date-time
                    nullable: true
                  days_analyzed:
                    type: integer
                    nullable: true
                  days_waived:
                    type: integer
                    nullable: true
                  days_taken:
                    type: integer
                    nullable: true
                  action_label:
                    type: string
                    nullable: true
        waiver_history:
          type: object
          properties:
            total:
              type: integer
            items:
              type: array
              items:
                allOf:
                - "$ref": "#/components/schemas/MealComplianceMyBreaksWaiver"
                - type: object
                  properties:
                    choice_label:
                      type: string
                      example: Waiving
                    valid_label:
                      type: string
                      example: Sep 2025 – Sep 2026
                    updated_on:
                      type: string
                      example: Sep 16, 2026
                    ended_reason:
                      type: string
                      nullable: true
                      description: Why an invalidated waiver ended, said to its own
                        employee.
        drafts:
          type: object
          properties:
            count:
              type: integer
            note:
              type: string
              nullable: true
    MealComplianceWaiverEmployeeOption:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          nullable: true
          description: Only where the People directory shows this caller email.
        initials:
          type: string
        avatar_url:
          type: string
        job_title:
          type: string
          nullable: true
          description: Only where the People directory shows this caller job titles.
        meta:
          type: string
          nullable: true
          description: The secondary line that tells two same-named people apart.
        detail:
          type: string
          nullable: true
        location:
          type: object
          nullable: true
          description: The Location the form suggests — their primary location here,
            when active.
          properties:
            id:
              type: integer
            name:
              type: string
        jurisdiction:
          type: object
          nullable: true
          description: The Jurisdiction the form suggests — their location's state
            one, else city, else country.
          properties:
            id:
              type: integer
            label:
              type: string
              example: California (CA)
            level:
              type: string
              enum:
              - country
              - state
              - city
        in_progress_waiver:
          type: object
          nullable: true
          description: A waiver already on its way for them — saving another needs
            confirming.
          properties:
            id:
              type: integer
            status:
              type: string
            status_label:
              type: string
    MealComplianceNewWaiverForm:
      type: object
      properties:
        title:
          type: string
        renewal_of:
          type: integer
          nullable: true
          description: The waiver `from_waiver_id` renewed.
        employee:
          nullable: true
          allOf:
          - "$ref": "#/components/schemas/MealComplianceWaiverEmployeeOption"
        in_progress_waiver:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            status:
              type: string
            status_label:
              type: string
            message:
              type: string
        create_override:
          type: object
          nullable: true
          description: How to save a second waiver beside the one on its way — send
            `param` = `value` with the create.
          properties:
            param:
              type: string
              example: create_beside_in_progress
            value:
              type: integer
            label:
              type: string
              example: Create another waiver anyway
        fields:
          type: object
          description: Each field's label, value (the default or the prefill), placeholder,
            choices and help.
          properties:
            employee:
              type: object
              description: label, required, value, placeholder — and `scope_note`,
                the line under the picker for a line manager (only their direct reports
                are listed), null for HR and administrators.
            location:
              type: object
            behavior_state:
              type: object
              description: "`shown: false` where per-period elections decide it."
            elections:
              type: object
              properties:
                shown:
                  type: boolean
                options:
                  type: array
                  items:
                    type: object
                periods:
                  type: array
                  items:
                    type: object
                    properties:
                      ordinal:
                        type: integer
                      label:
                        type: string
                      waived:
                        type: boolean
                      note:
                        type: string
                        nullable: true
                        description: '"Waivable only on days over 10 and up to 12
                          hours worked."'
            jurisdiction:
              type: object
            valid_from:
              type: object
            valid_until:
              type: object
            notes:
              type: object
        submit_label:
          type: string
    MealComplianceWaiverCreateRequest:
      type: object
      required:
      - user_id
      properties:
        user_id:
          type: integer
          description: 'An active member the caller may create a waiver for (a line
            manager: a direct report).'
        behavior_state:
          type: string
          enum:
          - waiving
          - taking
          description: Required where it is chosen (422 when absent or not one of
            these two). Where per-period elections decide it (`fields.elections.shown`
            in GET …/new) it is ignored and derived — waiving when any period is waived.
        elections:
          type: object
          additionalProperties:
            type: boolean
          description: 'Where per-period elections decide Behavior State: per meal
            period, keyed by ordinal (`{"1": false, "2": true}`). Every period the
            organization defines is recorded — one left out is taking; ordinals it
            does not define are ignored. Not an object → 422. Ignored where Behavior
            State is chosen.'
          example:
            '1': false
            '2': true
        location_id:
          type: integer
          nullable: true
          description: One of the form's active locations (422 otherwise).
        jurisdiction_id:
          type: integer
          nullable: true
          description: One of the form's active jurisdictions (422 otherwise).
        valid_from:
          type: string
          format: date
          nullable: true
          description: Absent or null — none; activating the waiver then starts it
            on the day it is activated.
        valid_until:
          type: string
          format: date
          nullable: true
          description: Today or later. Absent or null — no expiration (the form's
            default term is in `GET …/waivers/new`).
        notes:
          type: string
          nullable: true
          maxLength: 2000
          description: The employee sees this — it is printed on the signed waiver.
        create_beside_in_progress:
          type: integer
          description: Confirms saving beside the waiver already on its way — its
            id, from a 409's `create_override.value`.
    MealComplianceWaiverActionError:
      type: object
      description: A refused or failed action, in the words the web page uses. `error.details.page`
        is the waiver page as it is now (the body of `GET /meal_compliance/waivers/{id}`),
        so a client that pressed a stale button redraws the truth.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - esignature_off
              - esignature_unavailable
              - not_sendable
              - term_ended
              - former_member
              - no_email
              - signing_portal_closed
              - send_failed
              - not_activatable
              - invalid
              - not_invalidatable
              - invalidate_failed
              - state_changed
              - not_resendable
              - resend_failed
              - not_revocable
              - revoke_failed
              - not_declinable
              - reason_too_long
              - decline_failed
              - forbidden
              - own_waiver
              - not_signable
              - consent_required
              - signature_invalid
              - date_invalid
              - signature_too_large
              - fields_missing
              - sign_failed
            message:
              type: string
            details:
              type: object
              properties:
                page:
                  "$ref": "#/components/schemas/MealComplianceWaiverDetails"
      example:
        error:
          code: not_activatable
          message: Only a draft waiver, or a pending one with no live signing link,
            can be activated — this one is active.
          details:
            page:
              waiver:
                id: 904
                status: active
              permissions:
                can_activate: false
    MealComplianceWaiverDetails:
      type: object
      properties:
        waiver:
          type: object
          description: The hero and Waiver Information. Optional rows are null when
            they do not apply.
          properties:
            id:
              type: integer
            status:
              type: string
              enum:
              - draft
              - pending_signature
              - active
              - invalidated
              - expired
              - not_yet_in_effect
              description: The status as the badge reads it.
            status_label:
              type: string
              description: 'The badge''s words: "Pending — needs sending", "Pending
                — link expired", "Withdrawn", "Declined", "Discarded", "Not yet in
                effect", …'
            tone:
              type: string
              nullable: true
              enum:
              - primary
              - success
              - warning
              - danger
              - neutral
              - info
              -
            employee:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
                email:
                  type: string
                  nullable: true
                  description: Where the People directory shows this caller email,
                    or on the caller's own waiver.
                initials:
                  type: string
                avatar_url:
                  type: string
                  description: Absolute URL; a generated initials tile when the person
                    has no photo.
                job_title:
                  type: string
                  nullable: true
                  description: Their job title in this organization (the hero line).
                active_member:
                  type: boolean
                  description: False for a former employee ("No longer a member").
            behavior:
              type: object
              properties:
                state:
                  type: string
                  enum:
                  - waiving
                  - taking
                label:
                  type: string
                icon:
                  type: string
                  description: A Font Awesome icon name (forward, utensils).
            elections:
              type: array
              description: Per meal period, when the waiver records them; empty for
                a single election.
              items:
                type: object
                properties:
                  period_ordinal:
                    type: integer
                  label:
                    type: string
                  waived:
                    type: boolean
                  choice_label:
                    type: string
            elections_summary:
              type: string
              nullable: true
              description: The "Meal period elections" row; null when the waiver records
                none.
            jurisdiction_name:
              type: string
              nullable: true
              description: Null when none is set; the page prints "Not specified".
            location:
              type: object
              nullable: true
              properties:
                id:
                  type: integer
                name:
                  type: string
            valid_from:
              type: string
              format: date
              nullable: true
              description: Null prints "Not set".
            valid_from_tag:
              type: object
              nullable: true
              description: '"Starts in N days" beside a Valid From still ahead.'
              properties:
                days:
                  type: integer
                label:
                  type: string
                tone:
                  type: string
                  enum:
                  - info
            valid_until:
              type: object
              description: The Waivers register's shape, plus `note`.
              properties:
                date:
                  type: string
                  format: date
                  nullable: true
                label:
                  type: string
                  nullable: true
                  description: '"No expiry" when the waiver has no end date.'
                days_until_expiry:
                  type: integer
                  nullable: true
                tag:
                  type: object
                  nullable: true
                  properties:
                    kind:
                      type: string
                      enum:
                      - expiring
                      - expired
                    label:
                      type: string
                    tone:
                      type: string
                      enum:
                      - warning
                      - danger
                note:
                  type: string
                  nullable: true
                  description: Why Activate is gone on a draft or pending waiver whose
                    term already ended.
            ended:
              type: object
              nullable: true
              description: How an invalidated waiver ended; null otherwise.
              properties:
                label:
                  type: string
                  description: Withdrawn / Declined / Discarded / Invalidated.
                at:
                  type: string
                  format: date-time
                  nullable: true
                reason:
                  type: string
                  description: Who ended it and why, in words (to the employee on
                    their own waiver).
                by:
                  type: object
                  nullable: true
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                by_line:
                  type: string
                  description: '"By Dana Lee." or "Acting user not recorded."'
            activated_by:
              type: object
              nullable: true
              description: Who put it on file without an in-app signature.
              properties:
                id:
                  type: integer
                name:
                  type: string
                note:
                  type: string
            notes:
              type: string
              nullable: true
            is_sample_data:
              type: boolean
            created_at:
              type: string
              format: date-time
            updated_at:
              type: string
              format: date-time
        esignature:
          type: object
          nullable: true
          description: E-Signature Status, once a request exists.
          properties:
            request_id:
              type: integer
            name:
              type: string
            status:
              type: string
              enum:
              - draft
              - sent
              - viewed
              - partially_signed
              - completed
              - declined
              - voided
              - expired
            status_label:
              type: string
            decline_reason:
              type: string
              nullable: true
              description: 'The reason the signer gave when they declined ("Reason
                given: “…”") — for the signer, anyone who may act on this employee''s
                waivers (HR, a Meal Compliance app admin, their line manager), and
                anyone E-Signature lets read it.'
            sent_at:
              type: string
              format: date-time
              nullable: true
            signing_link:
              type: object
              nullable: true
              description: While the request is out — when the employee's link stops
                working.
              properties:
                expires_at:
                  type: string
                  format: date-time
                expired:
                  type: boolean
                label:
                  type: string
                  description: '"Signing link valid until" / "Signing link expired".'
            signed_copy:
              type: object
              nullable: true
              description: The signed copy, only where E-Signature would let this
                caller open it. `GET /esignature/{esignature_request_id}` answers
                a time-limited `signed_document_url`.
              properties:
                esignature_request_id:
                  type: integer
                download_path:
                  type: string
        behavior_history:
          type: object
          description: The employee's detected break-pattern windows, newest first,
            at most `limit`.
          properties:
            limit:
              type: integer
              example: 20
            items:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: integer
                  label:
                    type: string
                    description: '"Sep 19 – Oct 3: skipped 13 of 14 days".'
                  detected_pattern:
                    type: string
                  previous_pattern:
                    type: string
                    nullable: true
                  window_start:
                    type: string
                    format: date
                    nullable: true
                  window_end:
                    type: string
                    format: date
                    nullable: true
                  detected_at:
                    type: string
                    format: date-time
                    nullable: true
                  days_analyzed:
                    type: integer
                    nullable: true
                  days_waived:
                    type: integer
                    nullable: true
                  days_taken:
                    type: integer
                    nullable: true
                  action_label:
                    type: string
                    nullable: true
        notices:
          type: array
          description: Why an action the page would offer is missing.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - former_member
                - esignature_app_off
                - no_email
                - signing_portal_closed
                - resend_former_member
              message:
                type: string
              icon:
                type: string
        permissions:
          type: object
          description: One flag per action — true only when this caller may take it
            on this waiver now.
          properties:
            can_send_for_signature:
              type: boolean
            can_activate:
              type: boolean
              description: Activate — never on the caller's own waiver.
            can_invalidate:
              type: boolean
              description: Invalidate — on the employer's initiative.
            can_record_employee_withdrawal:
              type: boolean
              description: Employee withdrew it (active) / Employee declined it (pending).
            can_discard_draft:
              type: boolean
              description: Discard draft / Discard (never sent) — a draft, or a pending
                waiver whose signature request never went out.
            can_resend_signing_link:
              type: boolean
            can_download_signed_waiver:
              type: boolean
            can_start_new_waiver:
              type: boolean
              description: '"Start a new waiver for …" on an ended waiver, "Start
                the next waiver for …" near expiry.'
            can_revoke_own_waiver:
              type: boolean
              description: Revoke my waiver / Withdraw before signing — the waiver's
                own employee.
            can_decline_to_sign:
              type: boolean
              description: Decline to Sign — the waiver's own employee, where Review
                & Sign is offered (POST …/decline). Where it is true, can_revoke_own_waiver
                is false on a pending waiver.
            can_sign_in_app:
              type: boolean
              description: The app's own Sign Document on its Review & Sign sheet
                (POST …/sign) — the same live signing request Decline answers.
            can_review_and_sign:
              type: boolean
              description: 'Review & Sign — the waiver''s own employee''s live signing
                link (the review_and_sign action''s sign_url and its read-only document
                web_view_url). Never under an admin''s View-as or for a sample waiver.
                On a read-only token it stays true while can_sign_in_app is false:
                show the document and send them to sign_url, which needs no API scope.'
        actions:
          type: array
          description: The permitted actions only, in the order and place the prototype
            draws them.
          items:
            type: object
            properties:
              key:
                type: string
                enum:
                - send_for_signature
                - activate
                - start_new_waiver
                - record_employee_withdrawal
                - invalidate
                - review_and_sign
                - decline_to_sign
                - revoke_own_waiver
                - discard_draft
                - resend_signing_link
                - download_signed_waiver
              label:
                type: string
              icon:
                type: string
                description: A Font Awesome icon name.
              tone:
                type: string
                enum:
                - primary
                - success
                - danger
              placement:
                type: string
                enum:
                - primary
                - pinned
                - menu
              confirm:
                type: object
                nullable: true
                description: The confirm sheet; null for an action that asks nothing
                  (a download, opening the New Waiver form).
                properties:
                  title:
                    type: string
                  message:
                    type: string
                  confirm_label:
                    type: string
              reason:
                type: string
                description: 'Invalidate / withdrawal / discard: the invalidation
                  source the action records (manual, employee_revoked).'
              discard:
                type: boolean
                description: 'discard_draft only: send `discard: true` with the press.'
              sign_url:
                type: string
                description: 'review_and_sign only: the caller''s own live signing
                  link (the web signing page).'
              web_view_url:
                type: string
                description: 'review_and_sign only: the waiver as a read-only document
                  for the app''s web view (GET /m/apps/meal-compliance/{id}/document)
                  — see My Breaks'' signing.web_view_url.'
              reason_field:
                type: object
                description: 'decline_to_sign: the optional reason the sheet asks
                  for (sent as `reason`).'
                properties:
                  label:
                    type: string
                    example: Reason (optional)
                  required:
                    type: boolean
                  max_length:
                    type: integer
                    example: 1000
              prefill:
                type: object
                description: 'start_new_waiver: what the New Waiver form opens with.'
                properties:
                  user_id:
                    type: integer
                  from_waiver_id:
                    type: integer
              esignature_request_id:
                type: integer
                description: download_signed_waiver only.
              download_path:
                type: string
                description: download_signed_waiver only.
    MealComplianceWaivers:
      type: object
      required:
      - scope
      - status
      - filters
      - locations
      - items
      - meta
      - permissions
      properties:
        scope:
          type: string
          enum:
          - all
          - team
          - self
          description: Whose waivers the list and the counts cover — the whole organization,
            the caller's direct reports and themself, or the caller alone.
        status:
          type: string
          description: The status chip the list is filtered to (`all` when none).
        search:
          type: string
          nullable: true
          description: The search the list honours, trimmed.
        location_id:
          type: string
          nullable: true
        sort:
          type: string
          nullable: true
          enum:
          - valid_until
          - employee
          -
        dir:
          type: string
          nullable: true
          enum:
          - asc
          - desc
          -
        filters:
          type: array
          description: 'The status chips in the register''s order: all, active, (not_yet_in_effect
            while it holds a waiver or is selected), pending_signature, invalidated,
            expired, draft. The counts of the statuses add up to `all`.'
          items:
            type: object
            required:
            - key
            - label
            - count
            - selected
            properties:
              key:
                type: string
                enum:
                - all
                - active
                - not_yet_in_effect
                - pending_signature
                - invalidated
                - expired
                - draft
              label:
                type: string
              count:
                type: integer
              selected:
                type: boolean
        locations:
          type: array
          description: 'The Location filter''s options: the locations owning a waiver
            the caller may see, plus every active location for a whole-organization
            viewer. Draw the filter only when there is more than one.'
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
        items:
          type: array
          items:
            "$ref": "#/components/schemas/MealComplianceWaiverCard"
        meta:
          type: object
          properties:
            total_count:
              type: integer
            total_pages:
              type: integer
            current_page:
              type: integer
            per_page:
              type: integer
            has_next_page:
              type: boolean
            has_prev_page:
              type: boolean
            filter_ignored:
              type: boolean
              description: Present when `status` was not recognised.
            filter_note:
              type: string
        permissions:
          type: object
          properties:
            can_create_waiver:
              type: boolean
              description: 'The + (New Waiver): the caller may create waivers (full
                access, or a line manager) and the token holds `write:meal_compliance`.'
            can_export:
              type: boolean
              description: The web register's Export — HR, business admins and Meal
                Compliance app admins.
            export_web_url:
              type: string
              format: uri
              nullable: true
              description: 'Where Export opens: the web register''s CSV download (it
                has no API endpoint), carrying this list''s status, search, location
                and sort, for the caller signed in on the web. Present exactly when
                `can_export`; null otherwise.'
    MealComplianceWaiverSearch:
      type: object
      required:
      - scope
      - query
      - searchable_by
      - placeholder
      - items
      - meta
      properties:
        scope:
          type: string
          enum:
          - all
          - team
          - self
          description: Whose waivers were searched, as on the register.
        query:
          type: string
          nullable: true
          description: The query the results honour, trimmed; null on the idle screen.
        searchable_by:
          type: array
          items:
            type: string
            enum:
            - name
            - email
          description: What the query matches. `email` only where the People directory
            shows this caller email — a search never confirms a hidden address.
        placeholder:
          type: string
          description: The search field's placeholder, naming what it searches.
        results_label:
          type: string
          nullable: true
          description: '"1 result" / "N results" over every page; null when there
            is nothing to show.'
        empty_state:
          type: object
          nullable: true
          description: The idle screen, or the no-results screen; null while there
            are results.
          properties:
            kind:
              type: string
              enum:
              - idle
              - no_results
            title:
              type: string
            body:
              type: string
        items:
          type: array
          items:
            allOf:
            - "$ref": "#/components/schemas/MealComplianceWaiverCard"
            - type: object
              properties:
                subtitle:
                  type: string
                  description: 'The line under the name: "Meal Waiver (California)
                    · North Warehouse" — "N/A" for a waiver with no jurisdiction,
                    no location part when it has none.'
        meta:
          type: object
          properties:
            total_count:
              type: integer
            total_pages:
              type: integer
            current_page:
              type: integer
            per_page:
              type: integer
            has_next_page:
              type: boolean
            has_prev_page:
              type: boolean
    MealComplianceWaiverCard:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - draft
          - pending_signature
          - active
          - invalidated
          - expired
          - not_yet_in_effect
          description: The status as the badge reads it — the key the chips count.
        status_label:
          type: string
          description: 'The badge''s words, which can say more than the key: "Pending
            — needs sending", "Pending — link expired", "Withdrawn", "Declined", "Not
            yet in effect".'
        tone:
          type: string
          nullable: true
          enum:
          - primary
          - success
          - warning
          - danger
          - neutral
          - info
          -
          description: The badge colour, as on the web register and the Dashboard.
        employee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            email:
              type: string
              nullable: true
              description: Only where the People directory shows this caller email.
            initials:
              type: string
            avatar_url:
              type: string
              description: Absolute URL; a generated initials tile when the person
                has no photo.
            active_member:
              type: boolean
              description: False for a former employee — their waiver can no longer
                be sent or activated.
        behavior:
          type: object
          properties:
            state:
              type: string
              enum:
              - waiving
              - taking
            label:
              type: string
            icon:
              type: string
              description: A Font Awesome icon name (forward, utensils).
        elections_summary:
          type: string
          description: '"1st meal period: Waived · 2nd meal period: Taking" when the
            waiver records per-period elections, else "Waiving meal breaks" / "Taking
            meal breaks".'
        jurisdiction_name:
          type: string
          nullable: true
          description: Null when none is set; the web prints N/A.
        location:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        valid_from:
          type: string
          format: date
          nullable: true
        valid_until:
          type: object
          properties:
            date:
              type: string
              format: date
              nullable: true
            label:
              type: string
              nullable: true
              description: '"No expiry" when the waiver has no end date.'
            days_until_expiry:
              type: integer
              nullable: true
              description: Negative once the date has passed.
            tag:
              type: object
              nullable: true
              description: 'The tag beside the date, as on the web register: `expiring`
                (warning) inside the organization''s expiry-reminder window, `expired`
                (danger) for a draft or pending waiver past its term. Null otherwise
                — the status already says Expired, or the waiver was ended.'
              properties:
                kind:
                  type: string
                  enum:
                  - expiring
                  - expired
                label:
                  type: string
                  description: '"Expires in 3 days", "Expires today", "Expired".'
                tone:
                  type: string
                  enum:
                  - warning
                  - danger
        is_sample_data:
          type: boolean
          description: A seeded demo waiver — the web marks the row "Sample".
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    MessagingRules:
      type: object
      description: The organization's Messages rules as they apply to the caller —
        the same rules the server enforces on POST /messaging/threads, message attachments
        and the nightly retention purge.
      properties:
        can_start:
          type: boolean
          description: May the caller start a new conversation? When false, POST /messaging/threads
            answers 403 `thread_not_created`. Replying to a conversation the caller
            is already in is never limited by it.
        start_policy:
          type: string
          description: Who may start a new conversation, as one sentence to show the
            caller (e.g. "Managers and above can start a new conversation; everyone
            can reply.").
        group_messages:
          type: boolean
          description: Are group conversations (more than one recipient) turned on?
        attachments:
          type: boolean
          description: May files be attached to messages?
        retention_days:
          type: integer
          minimum: 0
          maximum: 3650
          description: Messages older than this many days are removed nightly; 0 means
            messages are kept forever.
    MessagingReactionOption:
      type: object
      properties:
        emoji:
          type: string
          description: The emoji glyph a client submits to POST .../reactions.
        key:
          type: string
          nullable: true
          example: like
          description: The reaction's catalog key; null for a glyph outside the catalog.
        label:
          type: string
          description: Human-readable name for the picker / aria-label (e.g. "Like").
        icon_url:
          type: string
          nullable: true
          description: Absolute URL of the drawn icon the web shows for this reaction.
            null ⇒ draw the emoji glyph.
    MessagingReactions:
      type: object
      description: Emoji reaction state for a message (mirrors the web thread pills).
      properties:
        counts:
          type: object
          additionalProperties:
            type: integer
          description: "Per-emoji tally, e.g. { \"\U0001F44D\": 4, \"❤️\": 2 }. Empty
            when there are no reactions."
        mine:
          type: array
          items:
            type: string
          description: The emoji the CALLER has left on this message (render these
            pills active).
        total:
          type: integer
          description: Total reaction count across all emoji.
    MomentShare:
      type: object
      required:
      - id
      - kind
      - subject_type
      - shared
      - page_open
      properties:
        id:
          type: integer
          example: 91
        kind:
          type: string
          enum:
          - recognition
          - award
          - anniversary
          - contest
          - certificate
          - year
          example: award
        kind_label:
          type: string
          description: What the kind is called, in the caller's language.
          example: Award
        subject_type:
          type: string
          description: The `subject_type` a POST names this moment by.
          enum:
          - recognition
          - award
          - anniversary
          - contest_win
          - certificate
          - year_in_recognition
          example: award
        subject_id:
          type: integer
          nullable: true
          description: The record's id; null for a year in recognition.
          example: 4812
        year:
          type: integer
          nullable: true
          description: The year, for a year in recognition.
          example:
        shared:
          type: boolean
          description: The person shares it (not stopped).
          example: true
        page_open:
          type: boolean
          description: The page and its card answer now — shared, the moment still
            live and theirs, and sharing on.
          example: true
        public_url:
          type: string
          nullable: true
          description: The link to post — the moment's public page, or for a certificate
            its verification page. Null while the page is not open.
          example: https://acme.workforce.mangoapps.com/moments/Zx9q3V1rT0aBcD2eFgH4iJ5kL6mN7oP8
        card_image_url:
          type: string
          nullable: true
          description: The card picture (PNG). Null while the page is not open.
          example: https://acme.workforce.mangoapps.com/moments/card/eyJfcmFpbHMiOnsibWVzc2FnZSI6.png
        linkedin_share_url:
          type: string
          nullable: true
          description: LinkedIn's share link for `public_url`. Null while the page
            is not open.
          example: https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Facme.workforce.mangoapps.com%2Fmoments%2FZx9q
        views_count:
          type: integer
          description: How many times the public page was opened.
          example: 12
        created_at:
          type: string
          format: date-time
        shared_at:
          type: string
          format: date-time
          nullable: true
        stopped_at:
          type: string
          format: date-time
          nullable: true
    MuscleMemoryPaginationMeta:
      type: object
      required:
      - page
      - per_page
      - total
      - total_pages
      properties:
        page:
          type: integer
          description: The page returned (a request past the end is clamped to the
            last page).
        per_page:
          type: integer
          description: Rows per page used.
        total:
          type: integer
          description: Lessons across all pages for the filters given.
        total_pages:
          type: integer
          minimum: 1
    MuscleMemoryLesson:
      type: object
      description: One lesson. Lists and reads only ever show `active` and `superseded`
        lessons; `forgotten` appears only in the response to the call that forgets
        one.
      required:
      - id
      - claim
      - domain
      - category
      - status
      - confidence
      - verified
      - evidence_count
      - reinforcement_count
      - source
      - recall_count
      - used_count
      - recall_audit_enabled
      - due_for_recheck
      properties:
        id:
          type: integer
        claim:
          type: string
          description: The lesson, as one sentence.
        domain:
          type: string
          description: Operational domain, for example `safety_hub` or `service_desk`.
        category:
          type: string
          description: What kind of lesson it is (for example `manual_lesson` for
            one a person added).
        status:
          type: string
          enum:
          - active
          - superseded
          - forgotten
          description: "`active` lessons can inform Ask AI answers (when confident
            enough); `superseded` ones were superseded by later evidence but stay
            listed and can be reinstated; `forgotten` ones are gone for good."
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: How strongly the business trusts the lesson, two decimals.
        verified:
          type: boolean
          description: True once a person has confirmed, corrected or reinstated it.
        verified_at:
          type: string
          format: date-time
          nullable: true
        verified_by:
          type: string
          nullable: true
          description: Name of the person who last confirmed, corrected or reinstated
            it.
        correction_note:
          type: string
          nullable: true
          description: The curator's note from the last correction, if any.
        evidence_count:
          type: integer
          description: How many pieces of evidence back the lesson.
        reinforcement_count:
          type: integer
          description: How many times later evidence or a repeated sentence reinforced
            it.
        source:
          type: string
          description: What learned it — `manual` for a lesson a person added, otherwise
            the name of the distiller.
        learned_at:
          type: string
          format: date-time
          nullable: true
        last_referenced_at:
          type: string
          format: date-time
          nullable: true
          description: Last time recall offered the lesson to an agent.
        recall_count:
          type: integer
          description: How many times recall offered the lesson to an agent. 0 for
            every lesson when `recall_audit_enabled` is false.
        used_count:
          type: integer
          description: How many times an agent declared it relied on the lesson. 0
            when `recall_audit_enabled` is false.
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: Last time an agent declared it relied on the lesson.
        recall_audit_enabled:
          type: boolean
          description: Whether the business keeps a usage history. When false, `recall_count`,
            `used_count` and `influence_history` read as empty for every lesson —
            it does not mean this lesson was never used.
        due_for_recheck:
          type: boolean
          description: True for a hand-written active lesson confirmed more than six
            months ago and not offered to an answer since — the app's "Due for a re-check"
            list (`status=recheck`). Confirming it again renews it.
    MuscleMemoryInfluenceEvent:
      type: object
      required:
      - recalled_at
      - surface
      properties:
        recalled_at:
          type: string
          format: date-time
        surface:
          type: string
          description: Where it was used, for example `ask_ai`.
        agent:
          type: string
          description: The agent that used it. Absent when not recorded.
        domain:
          type: string
          description: Domain of the question. Absent when not recorded.
        user:
          type: string
          description: Who received the answer. Present only for a caller who is a
            manager or above and only when the business records it (off by default);
            otherwise the key is absent.
    MuscleMemoryLessonDetail:
      allOf:
      - "$ref": "#/components/schemas/MuscleMemoryLesson"
      - type: object
        required:
        - influence_history
        properties:
          influence_history:
            type: array
            description: The most recent uses of this lesson, newest first, at most
              five.
            items:
              "$ref": "#/components/schemas/MuscleMemoryInfluenceEvent"
    MuscleMemoryLessonList:
      type: object
      required:
      - lessons
      properties:
        lessons:
          type: array
          items:
            "$ref": "#/components/schemas/MuscleMemoryLesson"
        meta:
          "$ref": "#/components/schemas/MuscleMemoryPaginationMeta"
        count:
          type: integer
          description: Search only. The number of lessons returned (not the number
            that match).
        per_page:
          type: integer
          description: Search only. The cap that applied (default 10, at most 50).
        truncated:
          type: boolean
          description: Search only. True when more lessons match than were returned.
            `page` does not reach them — narrow the query or add filters.
    MuscleMemoryCreateResult:
      type: object
      required:
      - outcome
      - lesson
      properties:
        outcome:
          type: string
          enum:
          - created
          - reinforced
          description: "`created` (HTTP 201) for a new lesson; `reinforced` (HTTP
            200) when the sentence matched a lesson the business already holds and
            that lesson was reinforced instead."
        lesson:
          "$ref": "#/components/schemas/MuscleMemoryLesson"
    MuscleMemoryCurationResult:
      type: object
      required:
      - lesson
      properties:
        lesson:
          "$ref": "#/components/schemas/MuscleMemoryLesson"
        notice:
          type: string
          description: Present when the lesson is still `superseded` after the call
            — it says the review or note was recorded but the lesson stays out of
            answers until it is reinstated — when the call changed nothing (`already_verified`,
            `already_forgotten`), and on `DELETE` of a lesson Contracts proposed for
            automatic approval, where it says an approval already accepted in Contracts
            keeps working. Absent otherwise.
        already_verified:
          type: boolean
          description: 'Present (true) only on `verify` of a lesson already confirmed
            and not due for a re-check: nothing changed.'
        already_forgotten:
          type: boolean
          description: 'Present (true) only on `DELETE` of a lesson already forgotten:
            nothing changed.'
    MuscleMemoryStats:
      type: object
      description: Aggregate figures for the business's real (non-sample) lessons.
      properties:
        total:
          type: integer
          description: Active lessons.
        verified:
          type: integer
          description: Active lessons a person has confirmed.
        unverified:
          type: integer
          description: Active lessons nobody has confirmed yet.
        superseded:
          type: integer
          description: Superseded lessons.
        forgotten:
          type: integer
          description: Lessons that have been forgotten (a count only; they are never
            listed).
        avg_confidence:
          type: string
          nullable: true
          description: Average confidence of the active lessons as a decimal string
            with two places (for example `"0.74"`); null when there are none.
        by_domain:
          type: object
          additionalProperties:
            type: integer
          description: Active lessons per domain, keyed by domain name.
        by_distiller:
          type: object
          additionalProperties:
            type: integer
          description: Lessons per source, keyed by distiller name (`manual` for lessons
            a person added). Counts every status except samples.
        minted_last_30:
          type: integer
          description: Lessons created in the last `minted_window_days` days.
        minted_window_days:
          type: integer
        most_recalled:
          type: array
          description: The most recently used active lessons (at most five), newest
            use first.
          items:
            type: object
            required:
            - id
            - claim
            properties:
              id:
                type: integer
              claim:
                type: string
              last_used_at:
                type: string
                format: date-time
                nullable: true
              last_referenced_at:
                type: string
                format: date-time
                nullable: true
        recalled_total:
          type: integer
          description: Active lessons an agent has ever relied on.
        recalled_last_30:
          type: integer
          description: Active lessons an agent relied on in the last `recall_window_days`
            days.
        considered_last_30:
          type: integer
          description: Active lessons recall offered to an agent in the last `recall_window_days`
            days.
        recall_window_days:
          type: integer
        impact:
          type: object
          description: What the lessons that intervened measurably changed.
          properties:
            measured:
              type: integer
              description: Interventions with a measured before/after result.
            improved:
              type: integer
              description: How many of those moved in the good direction.
            pending:
              type: integer
              description: Interventions still waiting for their after-measurement.
            avg_delta:
              type: number
              nullable: true
              description: Average measured change, direction-adjusted; null when
                nothing is measured.
        playbook_reliability:
          type: object
          description: How reliably the playbooks these lessons graduate into complete.
          properties:
            window_days:
              type: integer
            runs:
              type: integer
              description: Playbook runs that finished (completed or failed) in the
                window.
            completed:
              type: integer
            failed:
              type: integer
            success_rate:
              type: number
              nullable: true
              description: completed / runs, three decimals; null when there were
                no runs.
    NewsFeedPostFeedback:
      type: object
      description: |
        One piece of private feedback on a post, from the caller's side
        (NewsFeed::PostFeedback#api_payload). The `can_*` flags are what the
        caller may do now — all false on a token without `write:news_feed`.
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - open
          - resolved
        direction:
          type: string
          enum:
          - received
          - sent
          description: received = the caller is the post's author.
        body:
          type: string
        reply_body:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        replied_at:
          type: string
          format: date-time
          nullable: true
        resolved_at:
          type: string
          format: date-time
          nullable: true
        read:
          type: boolean
          description: The author has opened it.
        feed:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            title:
              type: string
        sender:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
        recipient:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
        can_reply:
          type: boolean
        can_resolve:
          type: boolean
        can_reopen:
          type: boolean
        can_withdraw:
          type: boolean
    NewsFeedEmojiKey:
      type: string
      description: One of the 14 News Feed reaction keys. The first eight are the
        `expressive` tier, the last six `action_oriented`. A business's reaction palette
        offers a subset (by default the eight expressive keys).
      enum:
      - like
      - love
      - celebrate
      - wow
      - sad
      - angry
      - support
      - insightful
      - acknowledged
      - need_clarification
      - concern
      - disagree
      - needs_discussion
      - escalate
    NewsFeedReactorUser:
      type: object
      nullable: true
      description: The person who reacted; null when their account is gone.
      required:
      - id
      - name
      - avatar_url
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
          description: Full name, else the account name.
        avatar_url:
          type: string
          format: uri
          description: Full URL of the person's avatar — their uploaded photo on the
            API's own host, else a ui-avatars.com tile drawing their initials.
    NewsFeedReactor:
      type: object
      required:
      - user
      - emoji_key
      - tier
      - reacted_at
      properties:
        user:
          "$ref": "#/components/schemas/NewsFeedReactorUser"
        emoji_key:
          "$ref": "#/components/schemas/NewsFeedEmojiKey"
        tier:
          type: string
          enum:
          - expressive
          - action_oriented
        reacted_at:
          type: string
          format: date-time
          description: When the reaction was set or last swapped.
    NewsFeedReactorGroup:
      type: object
      required:
      - emoji_key
      - tier
      - count
      - users
      properties:
        emoji_key:
          "$ref": "#/components/schemas/NewsFeedEmojiKey"
        tier:
          type: string
          enum:
          - expressive
          - action_oriented
        count:
          type: integer
          minimum: 1
          description: Reactors in this bucket (within the 200 returned), including
            any whose account is gone.
        users:
          type: array
          description: The bucket's reactors, most recent first; reactors whose account
            is gone are left out.
          items:
            "$ref": "#/components/schemas/NewsFeedReactorUser"
    NewsFeedReactorList:
      type: object
      required:
      - reactors
      - grouped
      - total
      properties:
        reactors:
          type: array
          description: Most recent first, at most 200.
          items:
            "$ref": "#/components/schemas/NewsFeedReactor"
        grouped:
          type: array
          description: The same reactors by emoji, largest bucket first.
          items:
            "$ref": "#/components/schemas/NewsFeedReactorGroup"
        total:
          type: integer
          minimum: 0
          maximum: 200
          description: Number of entries in `reactors` (capped at 200 with it).
    NewsFeedReactionRecord:
      type: object
      description: One person's reaction on a post or a comment; exactly one of `feed_id`
        / `comment_id` is set.
      required:
      - id
      - feed_id
      - comment_id
      - emoji_key
      - tier
      - user_id
      - reacted_at
      - created_at
      properties:
        id:
          type: integer
        feed_id:
          type: integer
          nullable: true
          description: Set for a reaction on a post; null on a comment's.
        comment_id:
          type: integer
          nullable: true
          description: Set for a reaction on a comment; null on a post's.
        emoji_key:
          "$ref": "#/components/schemas/NewsFeedEmojiKey"
        tier:
          type: string
          enum:
          - expressive
          - action_oriented
        user_id:
          type: integer
        reacted_at:
          type: string
          format: date-time
          description: When the reaction was set or last swapped.
        created_at:
          type: string
          format: date-time
    NewsFeedMentionableUser:
      type: object
      required:
      - id
      - name
      - avatar_url
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
          description: With `q`, the person's display name (never blank). With a blank
            `q`, their full name, else the account name.
        avatar_url:
          type: string
          nullable: true
          format: uri
          description: Full URL of the person's avatar — their photo, else an initials
            tile.
        meta:
          type: string
          description: 'Only with `q`. The secondary line under the name: job title
            and work location (each only as far as the People directory shows it to
            the caller) and email, joined by " · ". May be an empty string.'
        detail:
          type: string
          description: 'Only with `q`, and only when the caller may see job titles
            in the directory and the person has one: their job title.'
    NewsFeedPostAnalytics:
      type: object
      required:
      - feed_id
      - headline
      - published_at
      - must_read
      - metrics
      - timeline
      properties:
        feed_id:
          type: integer
        headline:
          type: string
          nullable: true
          description: The post's headline as plain text (its body's first line).
        published_at:
          type: string
          format: date-time
          nullable: true
          description: Null while the post has not gone out.
        must_read:
          type: boolean
          description: Whether the post is a must-read — only a must-read collects
            acknowledgements, so a client shows the Acknowledged figure only when
            this is true.
        metrics:
          "$ref": "#/components/schemas/NewsFeedPostAnalyticsMetrics"
        timeline:
          type: array
          items:
            "$ref": "#/components/schemas/NewsFeedAnalyticsDay"
        names_hidden:
          "$ref": "#/components/schemas/NewsFeedAnalyticsNamesHidden"
    NewsFeedPostAnalyticsMetrics:
      type: object
      required:
      - audience_size
      - read_count
      - read_pct
      - read_pct_label
      - acknowledged_count
      - acknowledged_pct
      - acknowledged_pct_label
      - reactions_count
      - action_item_total
      - action_item_completed_count
      - action_completion_pct
      - last_recomputed_at
      properties:
        audience_size:
          type: integer
          description: People in the post's audience snapshot (the named people only,
            while `names_hidden` is present).
        read_count:
          type: integer
          description: Audience members with a qualified read (scrolled 80% or more).
        read_pct:
          type: number
          format: float
          description: read_count / audience_size × 100, one decimal; 0.0 with no
            audience.
        read_pct_label:
          type: string
          nullable: true
          description: The rate as a person reads it — a whole number with `%`, `<1%`
            / `>99%` at the ends; null with no audience.
          example: 33%
        acknowledged_count:
          type: integer
          description: Audience members who acknowledged the post.
        acknowledged_pct:
          type: number
          format: float
        acknowledged_pct_label:
          type: string
          nullable: true
          example: ">99%"
        reactions_count:
          type: integer
          description: Reactions on the post itself (not its comments).
        action_item_total:
          type: integer
          description: Action items raised on the post or its comments.
        action_item_completed_count:
          type: integer
          description: Action items someone has marked done.
        action_completion_pct:
          type: number
          format: float
          description: 0.0 when there are no action items.
        last_recomputed_at:
          type: string
          format: date-time
          nullable: true
    NewsFeedAnalyticsDay:
      type: object
      required:
      - date
      - read_pct
      - ack_pct
      properties:
        date:
          type: string
          format: date
          description: A day in the business's time zone.
        read_pct:
          type: number
          format: float
          description: Cumulative read rate at the end of the day, one decimal.
        ack_pct:
          type: number
          format: float
          description: Cumulative acknowledgement rate at the end of the day, one
            decimal.
    NewsFeedAnalyticsNamesHidden:
      type: object
      description: Present only while a works council hides some of the audience's
        names and that group is under the company's smallest group. The figures beside
        it count only the people who may be named.
      required:
      - count
      - group_count
      - reason
      properties:
        count:
          type: integer
          nullable: true
          description: Always null here — the hidden people are not counted one by
            one.
        group_count:
          type: integer
          nullable: true
          description: The hidden group's size; null while it is under the company's
            smallest group.
        fewer_than:
          type: integer
          description: Present instead of a size while the group is under the company's
            smallest group — that floor.
        reason:
          type: string
          example: Names are hidden for people in Germany (works council).
    NewsFeedKnowledgeBaseEntry:
      type: object
      required:
      - id
      - feed_id
      - comment_id
      - question_title
      - answer_excerpt
      - answerer_name
      - segment
      - marked_at
      - status
      properties:
        id:
          type: integer
        feed_id:
          type: integer
          description: The question post.
        comment_id:
          type: integer
          description: The comment marked as the answer.
        question_title:
          type: string
          description: The question post's headline, at most 500 characters (empty
            when the post is gone).
        answer_excerpt:
          type: string
          description: The answer text, at most 500 characters.
        answerer_name:
          type: string
          nullable: true
          description: The person who gave the answer; null when there is none.
        segment:
          type: string
          nullable: true
          description: The question post's audience — "All employees", or the name
            of the first group it was aimed at; null when neither applies.
        marked_at:
          type: string
          format: date-time
          description: When the answer was marked correct.
        status:
          type: string
          enum:
          - active
          - outdated
    NewsFeedKnowledgeBaseAdminEntry:
      allOf:
      - "$ref": "#/components/schemas/NewsFeedKnowledgeBaseEntry"
      - type: object
        required:
        - outdated_at
        - outdated_by_user_id
        - outdated_reason
        properties:
          outdated_at:
            type: string
            format: date-time
            nullable: true
          outdated_by_user_id:
            type: integer
            nullable: true
          outdated_reason:
            type: string
            nullable: true
    NewsFeedKnowledgeBaseExportRow:
      type: object
      required:
      - id
      - question_title
      - question_body
      - answer_text
      - answerer_name
      - segment
      - marked_at
      - status
      - outdated_at
      - outdated_reason
      properties:
        id:
          type: integer
        question_title:
          type: string
          description: At most 500 characters.
        question_body:
          type: string
          description: At most 2,000 characters.
        answer_text:
          type: string
          description: At most 2,000 characters.
        answerer_name:
          type: string
          nullable: true
          description: The answerer's full name, else their email.
        segment:
          type: string
          nullable: true
        marked_at:
          type: string
          format: date-time
        status:
          type: string
          enum:
          - active
          - outdated
        outdated_at:
          type: string
          format: date-time
          nullable: true
        outdated_reason:
          type: string
          nullable: true
    NewsFeedSmeExpert:
      type: object
      required:
      - id
      - name
      - title
      - topic_id
      - designation
      properties:
        id:
          type: integer
          description: The expert's user id.
        name:
          type: string
          description: Full name, else name, else "Unnamed user".
        title:
          type: string
          nullable: true
          description: Job title.
        topic_id:
          type: integer
        designation:
          type: string
          enum:
          - assigned
          - measured
          description: "`assigned` — an admin named them on the SMEs page; `measured`
            — the weekly scoring ranks them an expert."
    NewsFeedKnowledgeGap:
      type: object
      required:
      - topic_id
      - topic_name
      - sme_count
      - post_volume_30d
      - severity
      - detected_at
      - needs_experts
      properties:
        topic_id:
          type: integer
        topic_name:
          type: string
          description: The topic's name. Only active topics are listed.
        sme_count:
          type: integer
          description: Experts the topic has now — added by an admin or found automatically,
            each person once.
        post_volume_30d:
          type: integer
          description: Published posts on the topic in the last 30 days.
        severity:
          type: string
          enum:
          - high
          - medium
          - low
        detected_at:
          type: string
          format: date-time
          description: When this answer was computed (the time of the request).
        needs_experts:
          type: boolean
          description: True for `high` and `medium` — the topics the Experts page
            lists under "Topics that need experts".
    NewsFeedCcPreference:
      type: object
      required:
      - cc_preference
      - effective
      properties:
        cc_preference:
          type: string
          enum:
          - auto
          - 'on'
          - 'off'
          description: What the caller chose (`auto` until they choose).
        effective:
          type: string
          enum:
          - 'on'
          - 'off'
          description: What the player should do now — `auto` resolved against the
            caller's language.
    NewsFeedReadAloudPreference:
      type: object
      required:
      - tts_voice
      - read_aloud_speed
      properties:
        tts_voice:
          type: string
          nullable: true
          enum:
          - neutral
          - male
          - female
          -
          description: The caller's voice; null = the default.
        read_aloud_speed:
          type: number
          nullable: true
          enum:
          - 0.75
          - 1.0
          - 1.25
          - 1.5
          - 2.0
          -
          description: The caller's speed; null = normal speed (1.0).
          example: 1.25
    NewsFeedVoiceTranscript:
      type: object
      required:
      - id
      - status
      - transcript_text
      - failure_reason
      properties:
        id:
          type: integer
        status:
          type: string
          enum:
          - pending
          - completed
          - failed
        transcript_text:
          type: string
          nullable: true
          description: The text, once `completed`.
        failure_reason:
          type: string
          nullable: true
          description: Why it `failed`, as a sentence to show the person — "No speech
            detected in the recording.", the AI usage policy's sentence, or "We couldn't
            transcribe this recording. Please try again." after three failed attempts.
    NewsFeedTtsAudio:
      type: object
      required:
      - audio_url
      - expires_at
      - cached
      properties:
        audio_url:
          type: string
          format: uri
          description: An MP3 to stream — a signed link valid for one hour.
        expires_at:
          type: string
          format: date-time
          description: When `audio_url` stops working — one hour after this call,
            or sooner when the cached audio expires first (24 hours after it was generated).
        cached:
          type: boolean
          description: True when the audio was already generated (nobody was charged).
        language:
          type: string
          description: The language the audio speaks — `en` for the post's original
            text, else the translation's language code.
          example: ko
        title:
          type: string
          description: The post's headline in that language, for the player.
    NewsFeedAiRateLimited:
      type: object
      description: The per-person hourly limit was reached. Answered with HTTP 200.
      required:
      - ok
      - error
      - code
      properties:
        ok:
          type: boolean
          enum:
          - false
        error:
          type: string
          example: You've reached the hourly limit for this feature. Try again after
            3:00 PM.
        code:
          type: string
          enum:
          - rate_limited
        retry_at:
          type: string
          format: date-time
          description: When the hourly limit lifts (the start of the next UTC hour),
            the same moment the `error` sentence names.
    NewsFeedSubtitleStatus:
      type: object
      required:
      - status
      properties:
        status:
          type: string
          enum:
          - pending
          - failed
          - not_applicable
          - not_found
    NotesCreateRequest:
      type: object
      description: Top-level fields (not nested under a `note` key).
      required:
      - content
      - notable_type
      - notable_id
      properties:
        content:
          type: string
          minLength: 1
          maxLength: 5000
        notable_type:
          type: string
          enum:
          - PerformanceReview
          - Goal
          - Apps::Epms::Meeting
          - DevelopmentPlan
          - User
          description: The kind of record the note hangs on. Alias `entity_type`.
        notable_id:
          type: integer
          description: The record's id in this business. Alias `entity_id`.
        entity_type:
          type: string
          description: Alias of `notable_type`, used when `notable_type` is absent.
        entity_id:
          type: integer
          description: Alias of `notable_id`, used when `notable_id` is absent.
        note_type:
          type: string
          enum:
          - general
          - observation
          - goal_progress
          - development
          - feedback
          - preparation
          - meeting_notes
          - action_item
          - reminder
          default: general
        visibility:
          type: string
          description: 'Who may read the note: `private` (the author), `shared` (the
            author and the person it is about), `manager_only` (managers and above)
            or `public` (all participants). The enum names `note_private` and `note_public`
            are accepted too. Defaults to private.'
          enum:
          - private
          - note_private
          - shared
          - manager_only
          - public
          - note_public
          default: note_private
    NotesRefusal:
      type: object
      required:
      - success
      - message
      properties:
        success:
          type: boolean
          example: false
        message:
          type: string
    NotificationsHomeNeedsYou:
      type: object
      description: Open asks waiting on this user, grouped by subject.
      required:
      - title
      - ordering
      - total_count
      - unread_count
      - entries
      properties:
        title:
          type: string
          description: Section heading, ready to render — in the user's language when
            the business translates the web app.
          example: Needs you
        ordering:
          type: string
          enum:
          - longest_waiting_first
          description: 'The order `entries` is already in — urgent first, then longest
            waiting first. Render as received rather than re-sorting.

            '
        total_count:
          type: integer
          description: Open asks across the whole section.
          example: 12
        unread_count:
          type: integer
          description: Unread open asks across the whole section.
          example: 5
        entries:
          type: array
          items:
            "$ref": "#/components/schemas/NotificationsHomeNeedsYouEntry"
    NotificationsHomeNeedsYouEntry:
      type: object
      description: 'One row of the Needs-you section: a collapsed subject group (`group:
        true`) or a single ask that sat below the collapse threshold (`group: false`).
        Both shapes carry the same keys.

        '
      required:
      - group
      - key
      - label
      - icon
      - count
      - unread_count
      - high_priority
      - oldest_at
      - oldest_waiting_label
      properties:
        group:
          type: boolean
          description: 'True for a collapsed subject group, false for a single ask.
            Groups form only at 3+ items in the subject.

            '
          example: true
        key:
          type: string
          description: Subject key — pass to `?subject=` to filter to it.
          example: leave_notifications
        label:
          type: string
          description: 'Subject label, as the user''s notification settings name it
            — in the user''s language when the business translates the web app. Match
            on `key`, never on the label.

            '
          example: Time off
        title:
          type: string
          nullable: true
          description: 'The heading to render — the subject label for a group, the
            notification''s own title for a single ask.

            '
          example: Time off
        icon:
          type: string
          description: Font Awesome icon name, without the `fa-` prefix.
          example: umbrella-beach
        count:
          type: integer
          description: Open asks in this entry — renders as the count pill.
          example: 4
        unread_count:
          type: integer
          description: Unread asks in this entry — renders as the "N new" pill.
          example: 2
        high_priority:
          type: boolean
          description: 'True when any ask in the entry is high priority — render the
            **Urgent** pill.

            '
          example: true
        oldest_at:
          type: string
          format: date-time
          nullable: true
          description: 'Since when the longest-waiting ask in this entry has waited
            (UTC ISO 8601), the time the "longest waiting first" order reads: when
            the ask arrived, or, for a reminder that rolls several items up and states
            its oldest''s age ("15 form submissions … the oldest for 12 days"), when
            that oldest item began waiting.

            '
          example: '2026-08-19T03:39:20Z'
        oldest_waiting_label:
          type: string
          nullable: true
          description: '`oldest_at` as the web phrases it, ready to render after "oldest
            waiting".

            '
          example: 7 days
        notifications:
          type: array
          description: 'The entry''s rows, so expanding costs no second request. A
            single-ask entry holds exactly one.

            '
          items:
            "$ref": "#/components/schemas/Notification"
    NotificationsHomeActivity:
      type: object
      description: 'Everything that merely happened, counted per subject and never
        listed.

        '
      required:
      - title
      - ordering
      - total_count
      - unread_count
      - sections
      properties:
        title:
          type: string
          example: Activity
        ordering:
          type: string
          enum:
          - unread_first_then_newest
          description: The order `sections` is already in — subsections with something
            unread first, each set newest first.
        total_count:
          type: integer
          example: 440
        unread_count:
          type: integer
          description: Drives the "· N new" suffix on the section heading.
          example: 278
        sections:
          type: array
          items:
            "$ref": "#/components/schemas/NotificationsHomeActivitySection"
    NotificationsHomeActivitySection:
      type: object
      description: |
        One Activity subsection. Carries counts only — fetch its rows with `GET /notifications?filter=<key>` when the user expands it — this key is
            what the Activity group's "See all →" opens.
      required:
      - key
      - label
      - icon
      - count
      - unread_count
      - newest_at
      properties:
        key:
          type: string
          description: Subject key — pass to `?subject=` to list its rows.
          example: company_store
        label:
          type: string
          description: In the user's language when the business translates the web
            app — match on `key`.
          example: Company Store
        icon:
          type: string
          description: Font Awesome icon name, without the `fa-` prefix.
          example: store
        count:
          type: integer
          example: 139
        unread_count:
          type: integer
          description: Renders as the "N new" pill.
          example: 139
        newest_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the latest notification in this subsection was received
            (UTC ISO 8601).

            '
          example: '2026-08-26T05:52:17Z'
        latest_label:
          type: string
          nullable: true
          description: '`newest_at` in the inbox''s short form — "now", "45m", "3h",
            "2d", or "Mar 4" beyond a month. Ready to render after "latest".

            '
          example: 3h
    Notification:
      type: object
      properties:
        id:
          type: integer
          description: Unique notification ID
          example: 42
        title:
          type: string
          description: Notification title
          example: New shift assigned
        content:
          type: string
          description: Notification body content
          example: You have been assigned to the morning shift on Feb 20.
        notification_type:
          type: string
          description: Type/category of the notification
          example: system
        read:
          type: boolean
          description: Whether the notification has been read
          example: false
        read_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp when the notification was read (ISO 8601)
          example: '2026-02-19T10:30:00Z'
        archived:
          type: boolean
          description: Whether the notification is archived
          example: false
        archived_at:
          type: string
          format: date-time
          nullable: true
          description: 'When the notification was archived (ISO 8601), null when it
            is not archived. This — not created_at — is the sort key for the archived
            list, so a client holding a cached list can re-sort locally after an archive/unarchive
            instead of waiting for the next fetch.

            '
          example: '2026-02-19T11:45:00Z'
        action_url:
          type: string
          nullable: true
          description: Optional URL for the notification action
          example: "/shifts/123"
        link_withheld_app:
          type: string
          nullable: true
          description: 'When `action_url` is null because the app the notification
            leads into will not let this reader in, that app''s name — so a client
            can say "This opens in <App>, which isn''t open to you" rather than draw
            a row that silently does nothing. Null otherwise.

            '
          example: Asset Pro
        source_type:
          type: string
          nullable: true
          description: Polymorphic source type
          example: Shift
        source_id:
          type: integer
          nullable: true
          description: Polymorphic source ID
          example: 123
        metadata:
          type: object
          nullable: true
          description: Additional metadata as key-value pairs
          example: {}
        actions:
          type: array
          description: |
            The row's open inline actions (approve, acknowledge, a link, …), in
            order. A `link` action OPENS `url`; every other kind is performed
            with `POST /notifications/{notification_id}/actions/{id}/perform`.
            A decision (`approve`, `reject`, `resume`, `sign`) needs a token with
            the `write:approvals` scope, and every other kind (an acknowledgment,
            an attestation) `write:notifications` — the native app's sign-in
            tokens carry both; without it `perform` answers 403
            `insufficient_permissions`.

            Delete-level (owner decisions 2026-10-04, #6659 and #6721): EVERY
            decline — a decision that ends someone else's request — also requires
            the delete scope of the area the request belongs to (declining a leave
            request `destructive:leave_management`, a timesheet
            `destructive:timekeeping`, a contract `destructive:contracts`, an HR
            document `destructive:hr_file_manager`, an alert held for approval
            `destructive:safety_hub`, a broadcast `destructive:broadcasts`, …; an
            AI suggestion or an agent's paused step is the exception), and so do
            approving a nomination (`destructive:recognition`) and a store
            cancellation request's "Cancel & refund" (`destructive:company_store`);
            without it `perform` answers 403 `insufficient_permissions` naming the
            scope.
          items:
            type: object
            properties:
              id:
                type: integer
              kind:
                type: string
                example: acknowledge
              label:
                type: string
                example: I have read and accept
              url:
                type: string
                nullable: true
                description: "`link` actions only (mobile path); null for every other
                  kind."
              confirmations:
                type: array
                description: |
                  The statements the caller must affirm before `perform` runs
                  (a Policy Hub or Job Descriptions acknowledgment is a legal
                  attestation); `[]` when the action asks none. Show them and send
                  the affirmed keys back as `confirmations: [keys]` in the
                  `perform` body — a missing required key is 422
                  `confirmation_required` (`error.details.confirmations` lists them),
                  and an impersonation token is refused 403
                  `attestation_not_delegable` (only the person may attest).
                items:
                  type: object
                  properties:
                    key:
                      type: string
                    label:
                      type: string
                    required:
                      type: boolean
        created_at:
          type: string
          format: date-time
          description: Creation timestamp (ISO 8601)
          example: '2026-02-19T08:00:00Z'
        updated_at:
          type: string
          format: date-time
          description: Last update timestamp (ISO 8601)
          example: '2026-02-19T10:30:00Z'
    OffboardingUserRef:
      type: object
      description: A person on an offboarding plan (Api::V1::Offboarding::PlansController#serialize_user).
      required:
      - id
      - name
      - email
      properties:
        id:
          type: integer
        name:
          type: string
          description: The person's display name.
        email:
          type: string
          nullable: true
          description: Null for a person with no email address on file.
    OffboardingPlanSummary:
      type: object
      description: |
        One offboarding plan as `GET /offboarding/plans` lists it
        (`serialize_plan` without `detailed`).
      required:
      - id
      - plan_name
      - plan_type
      - status
      - completion_percentage
      - duration_days
      - termination_date
      - last_working_day
      - notice_date
      - voluntary
      - user
      - manager
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        plan_name:
          type: string
        plan_type:
          type: string
          enum:
          - voluntary
          - involuntary
          - retirement
          - end_of_contract
          - layoff
        status:
          type: string
          enum:
          - draft
          - active
          - completed
          - cancelled
        completion_percentage:
          type: number
          format: float
          nullable: true
          minimum: 0
          maximum: 100
          description: Share of the plan's tasks finished, 0–100, as a JSON number.
          example: 20.8
        duration_days:
          type: integer
          description: The plan's planned length in days.
        termination_date:
          type: string
          format: date
          nullable: true
        last_working_day:
          type: string
          format: date
          nullable: true
        notice_date:
          type: string
          format: date
          nullable: true
        voluntary:
          type: boolean
          nullable: true
        user:
          allOf:
          - "$ref": "#/components/schemas/OffboardingUserRef"
          description: The departing employee.
        manager:
          allOf:
          - "$ref": "#/components/schemas/OffboardingUserRef"
          nullable: true
          description: The plan's manager; null on older plans saved without one.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    OffboardingTaskSummary:
      type: object
      description: |
        One offboarding checklist task as `GET /offboarding/tasks` lists it
        (`serialize_task` without `detailed`).
      required:
      - id
      - plan_id
      - task_name
      - task_type
      - department
      - status
      - priority
      - required
      - sequence_order
      - due_date
      - is_overdue
      - days_remaining
      - assigned_to
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        plan_id:
          type: integer
        task_name:
          type: string
        task_type:
          type: string
          enum:
          - access_revocation
          - equipment_return
          - documentation
          - knowledge_transfer
          - exit_interview
          - compliance
          - other
          - final_paperwork
          - benefits_termination
        department:
          type: string
          enum:
          - IT
          - HR
          - Facilities
          - Payroll
          - Manager
          - Legal
          - Finance
          - Operations
        status:
          type: string
          enum:
          - pending
          - in_progress
          - completed
          - skipped
          - blocked
        priority:
          type: string
          enum:
          - critical
          - high
          - medium
          - low
          nullable: true
        required:
          type: boolean
          description: Whether the task must be finished for the plan to complete.
        sequence_order:
          type: integer
          nullable: true
          description: Position in the plan's checklist. Not unique — two templates
            applied to one plan can repeat a number.
        due_date:
          type: string
          format: date
          nullable: true
        is_overdue:
          type: boolean
          description: Past its due date, neither completed nor skipped, and its plan
            still under way (draft or active) — false on a completed or cancelled
            plan.
        days_remaining:
          type: integer
          nullable: true
          description: Days until the due date (negative when past it); null with
            no due date, once completed, or once its plan is completed or cancelled.
        assigned_to:
          type: object
          nullable: true
          description: The person the task is assigned to; null when unassigned.
          required:
          - id
          - name
          properties:
            id:
              type: integer
            name:
              type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    OffboardingDocumentSummary:
      type: object
      description: |
        One offboarding document as `GET /offboarding/documents` lists it
        (`serialize_document` without `detailed`).
      required:
      - id
      - plan_id
      - document_name
      - document_type
      - status
      - required
      - sequence_order
      - due_date
      - is_overdue
      - days_remaining
      - requires_esignature
      - esignature_status
      - signature_in_progress
      - signature_complete
      - file_attached
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        plan_id:
          type: integer
        document_name:
          type: string
        document_type:
          type: string
          enum:
          - separation_agreement
          - nda_reminder
          - return_checklist
          - exit_interview
          - cobra_notice
          - final_pay_stub
          - benefits_summary
          - exit_interview_form
          - nda_acknowledgment
          - benefits_information
          - final_paycheck
          - equipment_return
          - reference_letter
          - custom
          - other
        status:
          type: string
          enum:
          - pending
          - uploaded
          - verified
          - rejected
        required:
          type: boolean
        sequence_order:
          type: integer
          nullable: true
          description: Position in the plan's document list. Not unique.
        due_date:
          type: string
          format: date
          nullable: true
        is_overdue:
          type: boolean
          description: Past its due date, still pending, and its plan still under
            way (draft or active) — false on a completed or cancelled plan.
        days_remaining:
          type: integer
          nullable: true
          description: Days until the due date (negative when past it); null with
            no due date, once no longer pending, or once its plan is completed or
            cancelled.
        requires_esignature:
          type: boolean
          nullable: true
        esignature_status:
          type: string
          nullable: true
          description: |
            The e-signature state as stored, not validated against a fixed list.
            Values the app writes include `not_required`, `pending_signature`,
            `sent`, `viewed`, `signed`, `declined` and `expired`; null when no
            signature workflow was ever set up.
        signature_in_progress:
          type: boolean
          description: Sent for signature and not yet signed (`esignature_status`
            is `sent` or `viewed`).
        signature_complete:
          type: boolean
          description: "`esignature_status` is `signed`."
        file_attached:
          type: boolean
          description: A file has been uploaded to the document.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    OfficeIntegrationApp:
      type: object
      required:
      - slug
      - app_slug
      - name
      - icon
      - supported_hosts
      - capabilities
      properties:
        slug:
          type: string
          description: The record type's registry key — the `{app_slug}` the records
            paths take.
          example: service-desk-help
        app_slug:
          type: string
          description: The marketplace app the record type belongs to; several types
            may share it.
          example: service-desk
        name:
          type: string
          description: The app's name as the marketplace lists it, followed by the
            record type for an app with several ("Service Desk — Help requests").
          example: Service Desk — Help requests
        capabilities:
          type: object
          required:
          - save_revision
          properties:
            save_revision:
              type: boolean
              description: Whether `POST …/revisions` can save an edited document
                back for this type.
        icon:
          type: string
          description: A Font Awesome class list.
          example: fas fa-file-contract
        supported_hosts:
          type: array
          items:
            type: string
            enum:
            - word
            - excel
    OfficeIntegrationStatus:
      type: object
      nullable: true
      description: The record's status badge; null for a record type without one.
      required:
      - label
      - variant
      properties:
        label:
          type: string
          nullable: true
          example: Pending approval
        variant:
          type: string
          description: A Bootstrap colour variant (`success`, `warning`, `secondary`,
            …).
          example: warning
    OfficeIntegrationSearchResult:
      type: object
      required:
      - id
      - title
      - subtitle
      - status
      properties:
        id:
          type: integer
        title:
          type: string
        subtitle:
          type: string
          nullable: true
        status:
          "$ref": "#/components/schemas/OfficeIntegrationStatus"
    OfficeIntegrationField:
      type: object
      required:
      - label
      - value
      properties:
        label:
          type: string
        value:
          description: The field's value as the record type renders it (usually a
            string; may be null).
        variant:
          type: string
          description: Optional colour variant for the value.
    OfficeIntegrationAction:
      type: object
      required:
      - name
      - label
      properties:
        name:
          type: string
          description: The `{name}` to post to `…/actions/{name}`.
          example: approve
        label:
          type: string
          example: Approve
        variant:
          type: string
          description: Button colour variant (`primary`, `danger`, `secondary`, …).
        requires_reason:
          type: boolean
          description: When true, collect a non-blank `reason` and send it in the
            body.
        reason_label:
          type: string
          description: The prompt shown above the reason box.
        reason_max:
          type: integer
          description: The longest reason the action accepts.
    OfficeIntegrationBlockedAction:
      type: object
      required:
      - name
      - label
      - reason
      properties:
        name:
          type: string
        label:
          type: string
        reason:
          type: string
          description: Why the action cannot be taken here yet.
    OfficeIntegrationInsight:
      type: object
      required:
      - severity
      - message
      properties:
        severity:
          type: string
          example: warning
        message:
          type: string
    OfficeIntegrationRecord:
      type: object
      required:
      - id
      - title
      - subtitle
      - status
      - fields
      - actions
      - blocked_actions
      - ai_insights
      - web_url
      - etag
      - capabilities
      properties:
        id:
          type: integer
        title:
          type: string
        subtitle:
          type: string
          nullable: true
        status:
          "$ref": "#/components/schemas/OfficeIntegrationStatus"
        fields:
          type: array
          items:
            "$ref": "#/components/schemas/OfficeIntegrationField"
        actions:
          type: array
          description: The actions this person and token may take now.
          items:
            "$ref": "#/components/schemas/OfficeIntegrationAction"
        blocked_actions:
          type: array
          description: Actions shown disabled with the reason — the record cannot
            take them yet, or the token's scopes do not cover them. Posting one is
            refused.
          items:
            "$ref": "#/components/schemas/OfficeIntegrationBlockedAction"
        ai_insights:
          type: array
          items:
            "$ref": "#/components/schemas/OfficeIntegrationInsight"
        web_url:
          type: string
          nullable: true
          description: The record's page in the web app, as a host-relative path.
        etag:
          type: string
          nullable: true
          description: The record's current version tag — send it as `If-Match` when
            saving a revision.
        capabilities:
          type: object
          required:
          - save_revision
          properties:
            save_revision:
              type: boolean
              description: Whether `POST …/revisions` can save an edited document
                back for this record type.
    OfficeIntegrationRevisionResult:
      type: object
      required:
      - success
      additionalProperties: true
      description: The record type's result; keys beyond `success` vary by type.
      properties:
        success:
          type: boolean
          description: False when the record type refused the save (see `error`).
        revision_id:
          type: integer
          nullable: true
        version:
          type: integer
          nullable: true
          description: The new revision or version number.
        message:
          type: string
          example: Revision 3 saved
        error:
          type: string
          description: Present when `success` is false — why the save was refused.
        ai_analysis:
          description: Contracts only — the AI analysis of the new revision when one
            ran, else null.
        row_errors:
          type: array
          description: Spreadsheet record types — the rows that could not be read.
          items: {}
        previous_content_preview:
          type: string
          description: Offer letters only — the start of the letter's previous text.
    OfficeIntegrationActionResult:
      type: object
      required:
      - success
      additionalProperties: true
      description: The action's result; keys beyond `success` vary by action.
      properties:
        success:
          type: boolean
          enum:
          - true
        message:
          type: string
        redirect_url:
          type: string
          description: "`open_web` — the record's web page to open."
    PasswordManagerRefusal:
      type: object
      required:
      - success
      - error
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
        error_code:
          type: string
        errors:
          type: array
          items:
            type: string
    PasswordManagerEntrySummary:
      type: object
      description: An entry's summary — never its password or TOTP secret.
      properties:
        id:
          type: integer
        title:
          type: string
        entry_type:
          type: string
        username:
          type: string
          nullable: true
        url:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        category_icon:
          type: string
          nullable: true
        visibility:
          type: string
        favorite:
          type: boolean
        has_totp:
          type: boolean
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_accessed_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        owner:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
              nullable: true
        view_url:
          type: string
    PeopleChange:
      type: object
      description: One approved profile change as a write-back receiver sees it. Values
        are rebuilt at fetch time; none is stored.
      properties:
        change_id:
          type: integer
        record_type:
          type: string
          description: Absent for a profile change; `compliance_record` for a signed
            record (a policy acknowledgment), whose `change_type` is `policy_attestation`.
        change_type:
          type: string
          description: The profile field the change writes (phone, full_address, department_id,
            job_title, custom_… ), or `policy_attestation` for a signed record.
        label:
          type: string
          description: The field's label as admins see it.
        role:
          type: string
          enum:
          - owner
          - copy
          description: owner — this system is the master for the field; copy — it
            only receives the change.
        subject:
          type: object
          properties:
            user_id:
              type: integer
            employee_id:
              type: string
              nullable: true
            email:
              type: string
              nullable: true
        values:
          type: object
          additionalProperties:
            type: string
            nullable: true
          description: Attribute → value (dates ISO 8601, blank as null). full_address
            carries address, city, state, zip and, when set, address_line2 and country.
        effective_date:
          type: string
          format: date
          nullable: true
        approved_at:
          type: string
          format: date-time
          nullable: true
        sensitive:
          type: boolean
          description: The field is masked from third parties elsewhere in Workforce;
            handle the value accordingly.
        status:
          type: string
          enum:
          - dispatching
          - unknown
          - sent
          - confirmed
          description: The change's delivery state on the Workforce side.
    PolicyHubPolicySummary:
      type: object
      description: A policy as the list shows it. `id` opens it at `GET /policy_hub/policies/{id}`.
      required:
      - id
      - title
      - policy_type
      - requires_acknowledgment
      - updated_at
      properties:
        id:
          type: integer
        title:
          type: string
        policy_type:
          type: string
        category:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        version_number:
          type: integer
          nullable: true
        requires_acknowledgment:
          type: boolean
          description: Whether the policy asks its audience for an acknowledgment.
        published_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
    PolicyHubHandbookSummary:
      type: object
      description: A handbook as the list shows it. `id` opens it at `GET /policy_hub/handbooks/{id}`.
      required:
      - id
      - title
      - updated_at
      properties:
        id:
          type: integer
        title:
          type: string
        version_number:
          type: integer
          nullable: true
        updated_at:
          type: string
          format: date-time
    PolicyHubPolicyDetail:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        policy_type:
          type: string
        category:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        version_number:
          type: integer
          nullable: true
        requires_acknowledgment:
          type: boolean
        published_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
        content:
          type: string
          nullable: true
        asked:
          type: boolean
          description: 'Whether the policy is ASKING the caller to acknowledge it
            now: it asks for an acknowledgment and its current audience includes them.
            `deadline`, `overdue` and `days_until_deadline` describe an ask, so they
            are null / false when this is false.'
        applicable_states:
          type: array
          nullable: true
          items:
            type: string
          description: Retired free-text states, kept for older clients. Read `applies_to`.
        applies_to:
          type: object
          description: Who the policy is for — its one Applies to rule (Knowledge
            Layer A2).
          properties:
            everyone:
              type: boolean
              description: True when it is for every employee.
            label:
              type: string
              nullable: true
              description: How the policy page names it.
              example: Ohio, Indiana and Michigan · hourly
            summary:
              type: string
              nullable: true
              description: The rule in words; null for everyone.
              example: hourly, at stores and hub stores in Ohio, Indiana and Michigan
            for_label:
              type: string
              nullable: true
              description: The chip a reader it is NOT for sees; null when it is for
                everyone.
              example: For California employees
        applies_to_you:
          type: boolean
          description: Whether the policy is for the caller. False also when a local
            version covers them — `your_local_version` then names it.
        language:
          type: string
          description: The language the policy is written in (its own "Written in",
            else English).
          example: en
        company_policy:
          type: object
          nullable: true
          description: 'LOCAL VERSIONS only (Knowledge Layer X2): the company policy
            this one is a local version of.'
          properties:
            id:
              type: integer
            title:
              type: string
            version_number:
              type: integer
              nullable: true
        company_version_number:
          type: integer
          nullable: true
          description: LOCAL VERSIONS only — the company policy version this local
            version reflects.
        company_version_changed:
          type: boolean
          description: LOCAL VERSIONS only — true when the company policy published
            a major or legal change past `company_version_number` (the local version
            has drifted and needs review).
        your_local_version:
          type: object
          nullable: true
          description: COMPANY policies only — the caller's own local version when
            one covers them and they may read it (open and acknowledge that one);
            null otherwise.
          properties:
            id:
              type: integer
            title:
              type: string
            language:
              type: string
              example: es
        key_facts:
          type: array
          description: 'The policy''s approved key facts (Knowledge Layer A5) — approved
            in Verified Knowledge by an administrator or by whoever manages the policy
            — in the order they were read, only those in force: never one past its
            end date (`effective_until`), nor one read from a version the policy has
            materially changed since. The same facts Ask AI answers from. Empty when
            none are.'
          items:
            "$ref": "#/components/schemas/PolicyHubKeyFact"
        user_acknowledged:
          type: boolean
          description: Whether the caller has accepted the CURRENT version (false
            for a row HR flagged for re-acknowledgment).
        requires_re_acknowledgment:
          type: boolean
          description: True when HR flagged the caller's acceptance for re-acknowledgment
            (a new version). `acknowledged_at` then still holds the earlier acceptance
            while `user_acknowledged` is false.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          description: When the caller last accepted the policy; null if never.
        deadline:
          type: string
          format: date-time
          nullable: true
          description: When the acknowledgment is due; null when none is set or the
            policy is not asking the caller (`asked` false).
        overdue:
          type: boolean
          description: True when the policy is asking the caller and the deadline
            has passed.
        days_until_deadline:
          type: integer
          nullable: true
          description: Whole days until `deadline` (0 once it has passed); null when
            there is no deadline or `asked` is false.
        viewed_at:
          type: string
          format: date-time
          nullable: true
          description: When the caller first viewed it (the reader's "First viewed"
            row; the key is `viewed_at` — there is no `first_viewed_at`). Recorded
            only for a policy that asks the caller for an acknowledgment, so null
            for a purely informational one.
        valid_until:
          type: string
          format: date-time
          nullable: true
          description: When the caller's signature lapses and they will be asked to
            sign again (a policy with a renewal period); null when it does not lapse
            or they have not signed.
        receipt_url:
          type: string
          format: uri
          nullable: true
          description: The caller's signature receipt (a PDF, opened in a browser
            signed in as them); null when they hold no signature on record.
        requires_signature:
          type: boolean
          description: True when the acknowledgment the caller OWES is a signature,
            taken at `sign_url` in a browser — `POST …/acknowledge` refuses it (`esignature_required`).
            False when they owe nothing (`asked` false, or already accepted and not
            asked again).
        sign_url:
          type: string
          format: uri
          nullable: true
          description: The signing step, when `requires_signature`; else null.
        comprehension_required:
          type: boolean
          description: True when the caller owes the acknowledgment and must first
            pass the policy's comprehension check, taken on the web at `check_url`.
        check_url:
          type: string
          format: uri
          nullable: true
          description: The policy page holding the comprehension check, when `comprehension_required`;
            else null.
        required_training:
          type: array
          items:
            type: string
          description: Titles of the training the caller must complete before they
            can acknowledge it; empty when none, or when they owe nothing.
        training_openable:
          type: boolean
          description: 'False when `required_training` is not empty and the caller
            cannot open the Training app (it is off for the company, or not open to
            them): show the titles as text, not links.'
        training_access_notice:
          type: string
          nullable: true
          description: When `training_openable` is false, the sentence saying why
            and who can fix it, worded for the caller (an administrator is told they
            can); else null.
    PolicyHubKeyFact:
      type: object
      description: One key fact read from a policy and approved (Knowledge Layer A5).
      properties:
        key:
          type: string
          example: bereavement_leave_days_immediate_family
        label:
          type: string
          example: Bereavement leave, immediate family
        value:
          type: string
          example: '3'
        unit:
          type: string
          nullable: true
          example: days
        display_value:
          type: string
          example: 3 days
        effective_from:
          type: string
          format: date
          nullable: true
          description: When the fact holds from, when the policy names a date.
        effective_until:
          type: string
          format: date
          nullable: true
          description: When the fact stops holding, when the policy names one.
        as_of:
          type: string
          format: date
          nullable: true
          description: The date the fact holds from — its effective date, else the
            day its policy version was published.
        version_number:
          type: integer
          nullable: true
          description: The policy version the fact was read from.
        as_of_label:
          type: string
          nullable: true
          example: as of 1 Jan 2026 · v4
    PolicyHubAcknowledgment:
      type: object
      description: One of the caller's acknowledgment rows (Api::V1::PolicyHub::BaseController#acknowledgment_json)
        — what `POST /policy_hub/policies/{id}/acknowledge` answers under `acknowledgment`,
        and each entry of `GET /policy_hub/acknowledgments`' `history`. `id` is the
        ACKNOWLEDGMENT's id; the policy is `policy_id`.
      required:
      - id
      - policy_id
      - policy_title
      - acknowledged
      - acknowledged_at
      - viewed_at
      - requires_re_acknowledgment
      - signed_earlier_version
      - deadline
      properties:
        id:
          type: integer
          description: The acknowledgment's id (not the policy's).
          example: 313372
        policy_id:
          type: integer
          description: The policy acknowledged — open it at `GET /policy_hub/policies/{policy_id}`.
          example: 60
        policy_title:
          type: string
          nullable: true
          example: Code of Conduct
        acknowledged:
          type: boolean
          description: Whether the caller has accepted it. Stays true on a row HR
            flagged for re-acknowledgment — read `requires_re_acknowledgment` with
            it.
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          description: When the caller last accepted it; null while never accepted.
        viewed_at:
          type: string
          format: date-time
          nullable: true
          description: When the caller first viewed the policy — the same field, under
            the same name, as the policy detail's `viewed_at` (there is no `first_viewed_at`).
            Accepting marks the row viewed, so an accepted row carries one.
        requires_re_acknowledgment:
          type: boolean
          description: True when the caller is being ASKED to accept the policy again
            (a new version, and the policy's current audience still includes them)
            — the policy is then also in `pending`. Accepting it again clears this.
        signed_earlier_version:
          type: boolean
          description: 'True when the row was flagged for a new version but the policy
            no longer asks the caller (they left its audience): the record of an earlier
            signature, not a call to action. Never true together with `requires_re_acknowledgment`.'
        deadline:
          type: string
          format: date-time
          nullable: true
          description: The row's acknowledgment deadline. Accepting clears it, so
            it is almost always null here — set only on a row flagged for re-acknowledgment.
            Read due/overdue state from `pending`, not from here.
        version_acknowledged:
          type: integer
          nullable: true
          description: The policy version the caller accepted.
        signature_method:
          type: string
          nullable: true
          description: How it was accepted — `click`, `esignature`, `electronic`,
            `onboarding_task` or `inbox_attestation`.
        valid_until:
          type: string
          format: date-time
          nullable: true
          description: When the signature lapses and is asked for again; null when
            it does not.
        receipt_url:
          type: string
          format: uri
          nullable: true
          description: The caller's signature receipt (a PDF); null when none is on
            record.
    PolicyHubPendingAcknowledgment:
      description: 'A policy still asking the caller for an acknowledgment (Api::V1::PolicyHub::BaseController#pending_acknowledgment_json):
        the policy summary — `id` is the POLICY id — plus the deadline state of the
        caller''s acknowledgment row.'
      allOf:
      - "$ref": "#/components/schemas/PolicyHubPolicySummary"
      - type: object
        required:
        - deadline
        - overdue
        - days_until_deadline
        properties:
          deadline:
            type: string
            format: date-time
            nullable: true
            description: When the acknowledgment is due; null when no deadline is
              set.
          overdue:
            type: boolean
            description: True once `deadline` has passed.
          days_until_deadline:
            type: integer
            nullable: true
            description: Whole days until `deadline` (0 once it has passed); null
              when there is no deadline.
          requires_re_acknowledgment:
            type: boolean
            description: True when the caller accepted it before and is asked to accept
              it again.
          re_acknowledgment_reason:
            type: string
            nullable: true
            enum:
            - policy_updated
            - signature_renewal
            -
            description: 'Why it is asked again: `policy_updated` (a new version changed
              it) or `signature_renewal` (the signature lapsed; the text may be unchanged).
              Null for a first acknowledgment.'
    PolicyHubHandbookDetail:
      description: 'A handbook as `GET /policy_hub/handbooks/{id}` returns it: the
        summary plus its introduction and sections.'
      allOf:
      - "$ref": "#/components/schemas/PolicyHubHandbookSummary"
      - type: object
        required:
        - introduction
        - sections
        properties:
          introduction:
            type: string
            nullable: true
          my_acknowledgment:
            type: object
            nullable: true
            description: The caller's standing on the policies this handbook asks
              them to acknowledge, as the handbook page shows it; null when it asks
              them for none.
            properties:
              total:
                type: integer
                description: Policies in it that ask the caller.
              acknowledged:
                type: integer
                description: Of those
                how many the caller has accepted.:
              outstanding:
                type: integer
                description: Of those
                how many are still owed.:
              portal_outstanding:
                type: integer
                description: Of the outstanding
                how many can be signed from the handbook's public page.:
              acknowledged_at:
                type: string
                format: date-time
                nullable: true
                description: The caller's latest acceptance among them (may predate
                  the handbook).
          sections:
            type: array
            description: The sections in order (`position` ascending). A policy section
              whose policy is no longer published, or that the caller may not read,
              is left out.
            items:
              "$ref": "#/components/schemas/PolicyHubHandbookSection"
    PolicyHubHandbookSection:
      type: object
      required:
      - type
      - position
      - title
      - policy_id
      - content
      properties:
        type:
          type: string
          enum:
          - policy
          - custom
          - chapter_header
          - divider
          description: "`policy` — a published policy (open `policy_id`); `custom`
            — the handbook's own text (`content`); `chapter_header` — a chapter title;
            `divider` — a visual break with no title or content."
        position:
          type: integer
          description: The section's place in the handbook (ascending).
        title:
          type: string
          nullable: true
          description: The section's own title, or for a `policy` section without
            one, the policy's title. Null for a `divider`.
        policy_id:
          type: integer
          nullable: true
          description: "`policy` sections: the policy to open at `GET /policy_hub/policies/{policy_id}`
            (the body is not inlined). Other types do not show a policy — ignore it
            on them (normally null)."
        content:
          type: string
          nullable: true
          description: The text of a `custom` section; null for every other type.
    PracticeHubPaginationMeta:
      type: object
      required:
      - page
      - per_page
      - total
      - total_pages
      properties:
        page:
          type: integer
          description: The page returned.
        per_page:
          type: integer
          description: Rows per page used.
        total:
          type: integer
          description: Rows across all pages.
        total_pages:
          type: integer
    PracticeHubScenario:
      type: object
      required:
      - id
      - title
      - category
      - difficulty
      - visibility
      - official
      - certifiable
      - estimated_minutes
      - average_rating
      - attempts_count
      - personas
      properties:
        id:
          type: integer
        title:
          type: string
        category:
          type: string
          nullable: true
          description: Free-form snake_case topic (e.g. `customer_service`, `de_escalation`,
            `leadership`); not limited to a fixed list.
        difficulty:
          type: string
          enum:
          - beginner
          - intermediate
          - advanced
        visibility:
          type: string
          enum:
          - community
          - curated
          - official
        official:
          type: boolean
          description: True when `visibility` is `official`.
        certifiable:
          type: boolean
          description: Whether passing the scenario can count toward a certification.
        estimated_minutes:
          type: integer
          nullable: true
        average_rating:
          type: number
          format: float
          nullable: true
          description: Learners' average rating (two decimals); null before any rating.
        attempts_count:
          type: integer
        personas:
          type: array
          description: The AI characters the learner practises with, in their set
            order.
          items:
            type: object
            required:
            - name
            - role
            properties:
              name:
                type: string
              role:
                type: string
                nullable: true
    PracticeHubAttempt:
      type: object
      required:
      - id
      - scenario_id
      - scenario_title
      - status
      - passed
      - score
      - started_at
      - completed_at
      properties:
        id:
          type: integer
        scenario_id:
          type: integer
        scenario_title:
          type: string
          nullable: true
          description: The scenario's title; null if the scenario can no longer be
            loaded.
        status:
          type: string
          enum:
          - in_progress
          - completed
          - scored
          - abandoned
        scoring:
          type: string
          nullable: true
          enum:
          - pending
          - failed
          -
          description: 'A finished roleplay''s scoring run: `pending` while it is
            queued or running ("Being scored…" on the attempt page), `failed` when
            it gave up ("Scoring failed", with Try scoring again); null otherwise,
            and always null for a recording analysis.'
        passed:
          type: boolean
          nullable: true
          description: Null until the attempt is scored.
        score:
          type: integer
          nullable: true
          description: The scorecard's final score — a reviewer's override when set,
            else the overall score; null when there is no scorecard or no score yet.
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
    RecentEntitiesEmployeeList:
      type: object
      required:
      - entity_type
      - count
      - entities
      properties:
        entity_type:
          type: string
          enum:
          - employee
        count:
          type: integer
          description: How many entities are in this response.
        entities:
          type: array
          items:
            type: object
            required:
            - id
            - name
            - position
            - status
            - updated_at
            properties:
              id:
                type: integer
              name:
                type: string
              email:
                type: string
                nullable: true
                description: Only for a browser session or a token holding `read:users`
                  (`admin` and `read:all` count), and only where the People directory
                  shows email to the caller; left out otherwise. Null for a person
                  with no email address.
              position:
                type: string
                nullable: true
                description: The person's role type, else their effective role, else
                  "Employee"; null when the People directory hides Job Function from
                  the caller.
              status:
                type: string
                enum:
                - Active
                - Inactive
                description: Active while the person has an active membership in this
                  business on an active account; Inactive once they have left it.
              updated_at:
                type: string
                description: "`YYYY-MM-DD HH:MM` in the caller's time zone."
                example: 2026-10-09 14:05
    RecentEntitiesShiftList:
      type: object
      required:
      - entity_type
      - count
      - entities
      properties:
        entity_type:
          type: string
          enum:
          - shift
        count:
          type: integer
          description: How many entities are in this response.
        entities:
          type: array
          items:
            type: object
            required:
            - id
            - employee_name
            - date
            - time
            - location
            - status
            - updated_at
            properties:
              id:
                type: integer
              employee_name:
                type: string
                nullable: true
                description: The first assigned person's name; null when nobody is
                  assigned.
              date:
                type: string
                format: date
                description: The shift's start date in its location's time zone.
              time:
                type: string
                description: "`HH:MM - HH:MM` (start - end) in the shift location's
                  time zone."
                example: '09:00 - 17:00'
              location:
                type: string
                nullable: true
                description: The location's name; null for a virtual location or none.
              status:
                type: string
                enum:
                - scheduled
                - completed
                - cancelled
                - late_reported
                - absence_reported
              updated_at:
                type: string
                description: "`YYYY-MM-DD HH:MM` in the caller's time zone."
                example: 2026-10-09 14:05
    RecentEntitiesLocationList:
      type: object
      required:
      - entity_type
      - count
      - entities
      properties:
        entity_type:
          type: string
          enum:
          - location
        count:
          type: integer
          description: How many entities are in this response.
        entities:
          type: array
          items:
            type: object
            required:
            - id
            - name
            - address
            - status
            - updated_at
            properties:
              id:
                type: integer
              name:
                type: string
              address:
                type: string
              status:
                type: string
                enum:
                - Active
                - Inactive
              updated_at:
                type: string
                description: "`YYYY-MM-DD HH:MM` in the caller's time zone."
                example: 2026-10-09 14:05
    RecipientGroupParametersOption:
      type: object
      required:
      - id
      - name
      properties:
        id:
          type: integer
        name:
          type: string
    RecipientGroupParametersEmployee:
      type: object
      required:
      - id
      - name
      - email
      properties:
        id:
          type: integer
          description: The user's id.
        name:
          type: string
          description: '"First Last", trimmed; an empty string when both are blank.
            The legal first name — a preferred name is matched by `q` but not shown.'
        email:
          type: string
          nullable: true
          description: Null for the many members who have no email address.
    RecipientGroupParametersRequiredParameter:
      type: object
      required:
      - type
      - label
      - required
      properties:
        type:
          type: string
          enum:
          - location
          - department
          - affected_criteria
          description: 'Which list fills it: `location` → /recipient_group_parameters/locations,
            `department` → /recipient_group_parameters/departments, `affected_criteria`
            → /recipient_group_parameters/employees.'
        label:
          type: string
          enum:
          - Select Locations
          - Select Departments
          - Specify Affected Employees
          description: The prompt to show beside the input, fixed per `type`.
        required:
          type: boolean
          description: Always true.
    RecognitionConfig:
      type: object
      description: The tenant + viewer configuration behind `GET /recognitions/config`.
        Every caller receives every key; the role story lives in the VALUES.
      required:
      - module_enabled
      - module_label
      - viewer_role
      - permissions
      - features
      - economy
      - limits
      - default_visibility
      - visibility_options
      - values
      - tags
      - cards
      - programs
      properties:
        module_enabled:
          type: boolean
          description: Is Recognize on for this org AND visible to this viewer. Always
            true in a 200 — the endpoint 403s otherwise — and reported so a client
            can cache one settings shape.
          example: true
        module_label:
          type: string
          description: The app's display name. Terminology varies per org, and the
            name lives on the app record, so a console rename reaches the native shell
            with no client release.
          example: Recognitions
        viewer_role:
          type: string
          enum:
          - employee
          - manager
          - admin
          description: "`manager` = has direct reports. `admin` = a business admin/owner
            or a Recognitions app-admin, and outranks `manager` when the viewer is
            both. Drives the role-adaptive footer, the More menu and Team gating."
          example: manager
        permissions:
          type: object
          description: What THIS viewer may do. Each boolean is the same guard the
            corresponding write path enforces, so a control rendered from one of these
            is a control whose POST will be accepted.
          required:
          - can_give
          - can_nominate
          - can_boost
          - can_react
          - can_approve
          - can_quick_award
          - can_moderate
          properties:
            can_give:
              type: boolean
              description: May give peer recognition at all. False when the tenant
                switched peer recognition off and the viewer is neither a manager
                nor an admin.
              example: true
            can_nominate:
              type: boolean
              description: May file a nomination in AT LEAST ONE listed program right
                now. False means the picker would be empty — hide the entry point.
                Also false whenever award requests (Model A) are off.
              example: true
            can_boost:
              type: boolean
              description: Whether the Boost affordance exists for this viewer — the
                tenant has peer points on AND this viewer may give. Per-post eligibility
                (not your own give, not already boosted, enough allowance left) rides
                on each post's own `boost` block.
              example: false
            can_react:
              type: boolean
              description: The tenant's reactions switch. Defaults on.
              example: true
            can_approve:
              type: boolean
              description: May decide nominations and held posts — the reviewer rule
                the approvals queue and the decision endpoints enforce.
              example: true
            can_quick_award:
              type: boolean
              description: May give an instant manager award with no approval step.
                Requires BOTH the tenant's Quick Award switch and reviewer standing.
                The service still refuses an award to someone who is not a direct
                report — that check is per-recipient.
              example: true
            can_moderate:
              type: boolean
              description: May remove or revoke SOMEONE ELSE'S recognition. An author
                may always remove their own give; that is per-record and is reported
                on the item itself.
              example: false
        features:
          type: object
          description: The TENANT switches that decide which surfaces exist, separate
            from `permissions`, which decide what this viewer may do with them.
          properties:
            award_requests_enabled:
              type: boolean
              description: Model A — ad-hoc award requests (nominate → approve → award).
                Defaults on.
              example: true
            award_cycles_enabled:
              type: boolean
              description: Model B — time-boxed award cycles. Defaults off.
              example: false
            quick_award_enabled:
              type: boolean
              example: true
            comments_enabled:
              type: boolean
              example: true
            reactions_enabled:
              type: boolean
              example: true
            tags_enabled:
              type: boolean
              description: When false, `tags` is empty and the composer should hide
                the tag picker rather than rendering an empty one.
              example: true
            anonymous_allowed:
              type: boolean
              description: Whether `anonymous` appears in `visibility_options`. Defaults
                OFF — recognition is attributed by default.
              example: false
        economy:
          type: object
          description: The points economy, as this tenant configured it. All figures
            are zero while `points_enabled` is false, so a client never advertises
            a pot that cannot be spent.
          properties:
            points_enabled:
              type: boolean
              description: Peer redeemable points. Requires BOTH the admin toggle
                and a working Company Store integration — the give path refuses on
                either.
              example: true
            wallet_allowance:
              type: integer
              description: THIS viewer's monthly "points to give" allowance.
              example: 1000
            wallet_remaining:
              type: integer
              description: What is left of it this month. Resets monthly; does not
                carry over.
              example: 750
            wallet_tier:
              type: string
              enum:
              - ic
              - manager
              - leadership
              description: Which allowance band this viewer falls in.
              example: ic
            manager_allowance:
              type: integer
              description: The MANAGER band's configured monthly allowance, regardless
                of this viewer's own band — what a manager-facing screen budgets against.
                Reported to every viewer so one shell renders for all.
              example: 2000
            boost_tiers:
              type: array
              description: The pile-on amounts a Boost offers. The server rejects
                anything else. A post's own `boost` block narrows these to what the
                viewer's remaining allowance covers.
              items:
                type: integer
              example:
              - 5
              - 10
              - 25
            points_per_dollar:
              type: integer
              description: The store's conversion rate. The economy is STORED in dollars
                and PRESENTED in points; use this rather than assuming 100.
              example: 100
            point_tiers:
              type: array
              description: The give form's points selector, in order, always leading
                with the 0-point shout-out so "no points" is an offered choice rather
                than an empty field.
              items:
                type: object
                properties:
                  points:
                    type: integer
                    example: 250
                  label:
                    type: string
                    example: 250 pts
        limits:
          type: object
          description: What the server will actually accept — read from the constants
            the validations and the group fan-out are written from.
          properties:
            max_recipients:
              type: integer
              description: The most people ONE give may name.
              example: 50
            max_company_values:
              type: integer
              description: The most company values ONE give (or edit) may carry in
                `company_value_ids`.
              example: 3
            message_min:
              type: integer
              example: 10
            message_max:
              type: integer
              example: 1000
            nomination_title_min:
              type: integer
              example: 2
            nomination_title_max:
              type: integer
              example: 100
            nomination_description_min:
              type: integer
              example: 10
            nomination_description_max:
              type: integer
              example: 1000
            nomination_justification_max:
              type: integer
              example: 2000
            nomination_supporting_files_max_count:
              type: integer
              description: Attachments one nomination may carry.
              example: 5
            nomination_supporting_file_max_bytes:
              type: integer
              description: Per-file size cap, in bytes.
              example: 10485760
            nomination_supporting_file_extensions:
              type: array
              description: The extensions the uploader accepts. Use these for the
                file picker's filter rather than hardcoding a list.
              items:
                type: string
              example:
              - ".pdf"
              - ".png"
              - ".jpg"
              - ".docx"
              - ".xlsx"
              - ".csv"
              - ".txt"
            nomination_supporting_file_types_label:
              type: string
              description: The same set as human copy, for the uploader's hint line.
              example: PDF, Word, Excel, CSV, TXT, PNG, JPG, GIF or WEBP
        default_visibility:
          type: string
          enum:
          - public
          - private
          description: The composer's untouched default. A tenant configured non-public
            defaults its gives to `private` — and only then is `private` offered.
          example: public
        visibility_options:
          type: array
          description: The composer's visibility AND anonymity toggles, filtered to
            what this org allows, in composer order.
          items:
            type: object
            properties:
              value:
                type: string
                enum:
                - public
                - department
                - private
                - anonymous
                description: The wire value. `department` is the prototype's "my_department".
                  The legacy `team` synonym is never offered.
                example: department
              param:
                type: string
                enum:
                - visibility
                - is_anonymous
                description: Which request parameter this option is submitted under
                  — `anonymous` is the separate `is_anonymous` boolean, not a `visibility`
                  value.
                example: visibility
              label:
                type: string
                example: My Department
              description:
                type: string
                example: Visible to people in your department
        values:
          type: array
          description: The tenant's active company values, in picker order. Giving
            points REQUIRES one, so a brand-new tenant is seeded with a starter set
            on first read rather than being shown an empty picker.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 12
              name:
                type: string
                example: Customer First
              slug:
                type: string
                example: customer-first
              description:
                type: string
                nullable: true
                example: Puts customers at the center of every decision
              icon:
                type: string
                nullable: true
                description: Admin-set; fall back to your own neutral chip when null.
                example: fas fa-handshake
              color:
                type: string
                nullable: true
                example: "#0d6efd"
        tags:
          type: array
          description: The tag chips the composer offers. An admin-curated list is
            used verbatim; otherwise the tenant's own most-used tags of the last 30
            days topped up with platform defaults. Empty when `tags_enabled` is false.
          items:
            type: string
          example:
          - teamwork
          - excellence
          - innovation
        cards:
          type: object
          description: The award-card gallery, split by where each design comes from.
          properties:
            tenant:
              type: array
              description: Tenant-authored cards. Only designs that actually have
                art are listed.
              items:
                "$ref": "#/components/schemas/RecognitionAwardCard"
            gallery:
              type: array
              description: Central-gallery cards (the catalog also shown at mangoapps.com/templates/recognition).
                Empty — never an error — when the gallery is unreachable.
              items:
                "$ref": "#/components/schemas/RecognitionAwardCard"
            gallery_truncated:
              type: boolean
              description: 'True when the gallery page came back FULL: the catalog
                may hold designs this payload never listed. Say so in a picker''s
                "no match" state rather than letting a search miss read as "that card
                doesn''t exist".'
              example: false
        programs:
          type: object
          description: The nominate picker's vocabulary.
          properties:
            items:
              type: array
              items:
                "$ref": "#/components/schemas/RecognitionConfigProgram"
            total_count:
              type: integer
              description: The tenant's FULL nominatable-program count, so a client
                can say "3 of 12" and link to the paginated Programs screen.
              example: 3
            truncated:
              type: boolean
              description: Whether more programs exist than this payload listed.
              example: false
    RecognitionAwardCard:
      type: object
      description: One award-card design for the give composer's picker. Submit `award_template_id`
        VERBATIM — it is an integer for a tenant card and the string `"central:<slug>"`
        for a gallery card.
      properties:
        source:
          type: string
          enum:
          - tenant
          - gallery
          example: tenant
        id:
          type: integer
          nullable: true
          description: The local record id. Null for a gallery card, which has none.
          example: 8
        award_template_id:
          oneOf:
          - type: integer
          - type: string
          description: The value POST /recognition/give takes for this card.
          example: 8
        name:
          type: string
          example: Star Performer
        slug:
          type: string
          nullable: true
          example: star-performer
        default_message:
          type: string
          nullable: true
          description: The message the composer pre-fills when this card is picked.
            Tenant cards only.
          example: You went above and beyond.
        default_points:
          type: integer
          nullable: true
          example: 250
        category:
          type: string
          nullable: true
          description: Tenant cards only — the recognition category this design belongs
            to.
          example: Above & Beyond
        category_id:
          type: integer
          nullable: true
          example: 4
        description:
          type: string
          nullable: true
          description: Gallery cards only.
          example: A simple thanks.
        art_url:
          type: string
          nullable: true
          description: Absolute URL to the card art.
          example: https://acme.workforce.mangoapps.com/rails/active_storage/blobs/star.png
    RecognitionConfigProgram:
      type: object
      description: One nominatable program, trimmed to what a NOMINATE PICKER needs.
        The full card — points-remaining bar, per-viewer counts, window dates — is
        served by `GET /recognitions/programs`.
      properties:
        id:
          type: integer
          example: 3
        name:
          type: string
          example: Above & Beyond
        slug:
          type: string
          nullable: true
          example: above-and-beyond
        description:
          type: string
          nullable: true
          example: For work that goes well past the brief.
        program_type:
          type: string
          example: peer_to_peer
        program_type_label:
          type: string
          example: Peer to Peer
        can_nominate:
          type: boolean
          description: Whether this viewer may file a nomination RIGHT NOW — the same
            answer the submit path gives. Hide the entry rather than disabling it.
          example: true
        nomination_block_reason:
          type: string
          nullable: true
          description: Why not, in the viewer's own words. Null when they can. Render
            it — a disabled row with no reason and no next step is a dead end.
          example: Quarterly Star Award is a Manager Recognition program — only managers
            can nominate in it. Recognize a teammate through a peer-to-peer program
            instead.
        categories:
          type: array
          description: The program's active categories, in sort order.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 9
              name:
                type: string
                example: Customer Impact
              slug:
                type: string
                example: customer-impact
              icon:
                type: string
                example: star
              color:
                type: string
                example: "#0d6efd"
    RecognitionGiveRequest:
      type: object
      description: The give composer's fields, one per control on the screen. Every
        option catalog behind them is served by `GET /recognitions/config`, so a client
        renders the picker from the same source the server validates against.
      properties:
        recipient_ids:
          type: array
          description: The people to recognize, in the order the client picked them,
            up to `limits.max_recipients` (50). Only people `GET /recognitions/employee_suggestions`
            offers are acceptable — active members of this business, service / AI-agent
            principals excluded. The caller's own id is dropped rather than refused.
          items:
            type: integer
          example:
          - 812
          - 907
        recipient_id:
          type: integer
          nullable: true
          description: Single-recipient form, for a one-person give and for the "Recognize
            <person>" deep link. Ignored when `recipient_ids` is present.
          example: 812
        content:
          type: string
          description: The recognition message — 10..1000 characters (`limits.message_min`
            / `message_max`). May be blank ONLY when `award_template_id` names a card
            that carries a default message, which then fills it.
          minLength: 10
          maxLength: 1000
          example: You carried the migration all weekend — thank you.
        award_template_id:
          type: string
          nullable: true
          description: 'The "Pick a card" selection, taken verbatim from `config.cards[].award_template_id`:
            a tenant card''s integer id (as a string or a number), or `"central:<slug>"`
            for a gallery card. An unknown id, a stale slug or a gallery outage is
            not an error — the give simply posts without a card.'
          example: central:thank-you-star
        points:
          type: integer
          minimum: 0
          default: 0
          description: Reward points from the giver's own monthly allowance — one
            of `economy.point_tiers`. `0` is a plain shout-out. Requires a company
            value when positive. Silently 0 when the tenant has peer points switched
            off.
          example: 250
        company_value_ids:
          type: array
          maxItems: 3
          items:
            type: integer
          description: The core values this recognition celebrates (each one of `config.values`),
            primary first — up to `limits.max_company_values`. At least one is REQUIRED
            once `points` is positive.
          example:
          - 4
          - 9
        company_value_id:
          type: integer
          nullable: true
          description: One core value — the list's one-value form, read when `company_value_ids`
            is absent.
          example: 4
        recognition_tags:
          type: array
          description: Informal tags from `config.tags`. The first 5 are kept. A comma-separated
            string is accepted too.
          items:
            type: string
          example:
          - teamwork
          - above-and-beyond
        visibility:
          type: string
          enum:
          - public
          - department
          - private
          nullable: true
          description: One of `config.visibility_options`. **Omit it** to take the
            tenant's own default — do not send `public` as a fallback, which is how
            a private-by-default tenant's gives end up published publicly.
          example: department
        is_anonymous:
          type: boolean
          default: false
          description: Post without the giver's name. Forced false unless `features.anonymous_allowed`
            is true — anonymity is opt-in per tenant.
          example: false
        photo_url:
          type: string
          format: uri
          nullable: true
          description: A photo for this recognition, named by URL — the JSON equivalent
            of the web composer's file upload. Must be a **public HTTPS** URL serving
            a JPG, PNG, WebP or HEIC image under 10 MB; the format is decided by sniffing
            the bytes, not by the `Content-Type` header or the extension. The server
            fetches it inline and stores the bytes, so the response card's `photo_url`
            is OUR rendition and this link is never referenced again — a URL behind
            a login, a signed URL that expires, or one on a private network will not
            work. Anything unusable refuses the give with `invalid_photo_url` (422)
            and writes nothing. A group give stores one copy, shared by every row.
          example: https://cdn.example.com/uploads/install-day.jpg
    RecognitionGiveResult:
      type: object
      description: The created recognition, what the give actually did, and the caller's
        remaining allowance.
      required:
      - recognition
      - recognition_ids
      - status
      - message
      properties:
        recognition:
          "$ref": "#/components/schemas/RecognitionGiveCard"
        recognition_ids:
          type: array
          description: Every row the submission wrote — one per recipient. The card
            above collapses a group give into one row (as the feed does), so this
            is how a client addresses an individual row later, to open or delete it.
          items:
            type: integer
          example:
          - 4473
          - 4474
        status:
          type: string
          enum:
          - active
          - posting
          - pending_review
          - pending_approval
          description: What the give DID. `active` is live; `posting` is held for
            the automated content screen (the normal AI-moderation path) and publishes
            moments later; `pending_review` is a keyword/policy hold; `pending_approval`
            is routed to the recipient's manager. **Never assume a 201 means live.**
          example: active
        message:
          type: string
          description: The web flash for this `status`, word for word — safe to show
            as-is.
          example: Recognition for Priya Nair has been shared!
        giving_remaining:
          type: integer
          description: The caller's remaining monthly points-to-give allowance AFTER
            this give, so a wallet header updates without a second request. 0 when
            peer points are off.
          example: 750
        unread_notification_count:
          type: integer
          example: 3
    RecognitionGiveCard:
      type: object
      description: The created recognition as the FEED renders it — the same card
        shape `GET /recognitions/feed` returns, so a client can insert it optimistically
        and have the next refresh agree. A brand-new give has no reactions, comments
        or boosts, and its own author can never boost it.
      required:
      - type
      - kind
      - id
      - status
      - outcome_status
      properties:
        type:
          type: string
          enum:
          - recognition_post
          example: recognition_post
        kind:
          type: string
          enum:
          - recognition
          example: recognition
        id:
          type: integer
          example: 4471
        title:
          type: string
          nullable: true
        message:
          type: string
          nullable: true
          description: The recognition body — the giver's message, or the card's default
            copy when they left it blank.
        points:
          type: integer
          example: 250
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        group_recipients:
          type: array
          nullable: true
          description: Every recipient of the submission, for a group give. Always
            present and **null** for a single-recipient give — the field never disappears,
            so a client reads it the same way here, on the feed card and on the detail
            screen.
          items:
            "$ref": "#/components/schemas/RecognitionPersonCard"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          nullable: true
          description: The giver. **Null for an anonymous give**, even for the caller
            who just made it.
        anonymous:
          type: boolean
          example: false
        program:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
        company_value:
          type: string
          nullable: true
          description: The main (first) company value's name.
          example: Customer First
        company_values:
          type: array
          description: Every company value it celebrates, primary first — up to three.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Customer First
        occurred_at:
          type: string
          format: date-time
          description: When it was published, falling back to when it was created
            — a give held for the content screen has no publish time yet but still
            sorts.
        visibility:
          type: string
          example: public
        status:
          type: string
          description: The persisted column. A give awaiting the content screen reads
            `pending_review` here — branch on `outcome_status` (or the top-level `status`)
            instead, which tells `posting` apart from a real moderator hold.
          example: active
        outcome_status:
          type: string
          enum:
          - active
          - posting
          - pending_review
          - pending_approval
          description: The same value as the response's top-level `status`, repeated
            on the card.
          example: active
        tags:
          type: array
          items:
            type: string
        photo_url:
          type: string
          nullable: true
          description: The photo the give carried, as the stored WebP rendition —
            the same value `GET /recognitions/feed` and `GET /recognitions/posts/{id}`
            report, so the card can be inserted optimistically. Always present, **null**
            when the give sent no `photo_url`. On a group give every row shares this
            one image.
        award_art_url:
          type: string
          nullable: true
          description: Art of the card that was attached. For a gallery card this
            is a freshly materialized tenant asset, so the client cannot know it in
            advance.
        boost:
          "$ref": "#/components/schemas/RecognitionBoost"
    RecognitionGiveError:
      type: object
      description: A refused give. `error.code` is the rule that refused it.
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
              enum:
              - no_recipients
              - invalid_recipients
              - too_many_recipients
              - content_missing
              - invalid_photo_url
              - governance_blocked
              - content_rejected
              - invalid
              - giving_not_allowed
              - access_denied
            message:
              type: string
              description: Ready to show. For `governance_blocked` this is the guard's
                own sentence, which names the limit and when it lifts.
            details:
              type: object
              nullable: true
              description: 'Present on the two refusals a client can act on structurally:
                `max_recipients` for `too_many_recipients`, `recipient_ids` for `invalid_recipients`.'
    RecognitionDeletion:
      type: object
      description: The result of the ⋯ ▸ Delete action, returned by both `DELETE /recognitions/posts/{id}`
        (soft delete) and `DELETE /recognitions/awards/{id}` (admin revoke). `type`
        says which table the recognition came from and `status` the past tense it
        landed in, so a client can render the confirmation without re-deriving either.
      required:
      - id
      - type
      - deleted
      - status
      - already_deleted
      properties:
        id:
          type: integer
          example: 4471
          description: The removed recognition's id.
        type:
          type: string
          enum:
          - recognition_post
          - award
          description: "`recognition_post` for a peer give, `award` for an award or
            automated certificate."
        deleted:
          type: boolean
          example: true
          description: Always true on a 200 — the recognition is no longer live.
        status:
          type: string
          enum:
          - deleted
          - revoked
          description: The row's new status — `deleted` for a soft-deleted post, `revoked`
            for an award.
        already_deleted:
          type: boolean
          example: false
          description: True when the recognition was ALREADY removed and this request
            was a no-op (posts only — a re-issued award revoke answers 422 `not_revocable`).
            Points are never reversed twice.
        message:
          type: string
          nullable: true
          example: Recognition removed.
          description: A ready-to-show confirmation, worded as the web words it.
        unread_notification_count:
          type: integer
          example: 3
    RecognitionEdit:
      type: object
      description: The result of the ⋯ ▸ Edit action, returned by both `PATCH /recognitions/posts/{id}`
        and `PATCH /recognitions/awards/{id}`. `recognition` is the SAME card the
        matching `GET` returns — one serializer, so a client replaces its row from
        this response instead of re-fetching, and there is no edit-only shape to keep
        in step.
      required:
      - recognition
      - unchanged
      properties:
        recognition:
          "$ref": "#/components/schemas/RecognitionDetail"
        unchanged:
          type: boolean
          example: false
          description: 'True when the body matched what was already stored: nothing
            was written, no moderation screen ran and no notification fired. A client
            can skip its "saved" animation on this.'
        message:
          type: string
          nullable: true
          example: Recognition updated.
          description: A ready-to-show confirmation, worded as the web words it.
        unread_notification_count:
          type: integer
          example: 3
    RecognitionComment:
      type: object
      description: 'ONE comment on a recognition post or award. Threading is ONE level
        deep: a top-level comment carries its direct replies inline in `replies`,
        and a serialized reply always has `parent_id` set and `replies: []`. Only
        ACTIVE replies are inlined, so a reply held for moderation or removed never
        reaches a client. Reactions are summarized; `can_edit`/`can_delete` are per-viewer
        and match what the write endpoints enforce.'
      required:
      - id
      - item_type
      - item_id
      - content
      - status
      - created_at
      - can_edit
      - can_delete
      properties:
        id:
          type: integer
          example: 991
        parent_id:
          type: integer
          nullable: true
          example: 990
          description: null on a top-level comment; the id of the comment this one
            replies to otherwise. Same key POST accepts.
        item_type:
          type: string
          enum:
          - recognition_post
          - award
          description: The canonical commentable type of the parent recognition.
        item_id:
          type: integer
          example: 4471
          description: The parent recognition's id.
        author:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          nullable: true
          description: The comment's author (id, name, title, image).
        content:
          type: string
          example: Fantastic work — well deserved!
        status:
          type: string
          enum:
          - active
          - hidden
          - deleted
          description: "`active` normally; `hidden` when held by moderation; `deleted`
            after removal."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        edited_at:
          type: string
          format: date-time
          nullable: true
          description: When the author last edited the CONTENT (nil if never edited;
            unaffected by moderation status flips).
        edited:
          type: boolean
          description: True iff `edited_at` is present.
        time_ago:
          type: string
          nullable: true
          example: about 2 hours ago
        replies_count:
          type: integer
          example: 2
          description: How many ACTIVE replies are inlined below. Always 0 on a reply.
        replies:
          type: array
          description: The comment's ACTIVE direct replies, oldest-first. Always empty
            on a reply — threads are one level deep — so this recursion terminates
            after one hop.
          items:
            "$ref": "#/components/schemas/RecognitionComment"
        reactions:
          type: object
          properties:
            total:
              type: integer
              example: 3
            top_emojis:
              type: array
              items:
                type: object
                properties:
                  emoji:
                    type: string
                    example: "\U0001F44D"
                  count:
                    type: integer
                    example: 2
            my_reactions:
              type: array
              items:
                type: string
                example: "❤️"
              description: The viewer's own emojis on this comment.
        can_edit:
          type: boolean
          description: Whether THIS caller may edit the comment (author only).
        can_delete:
          type: boolean
          description: Whether THIS caller may delete the comment (author or a Recognitions
            moderator).
    RecognitionCommentPageMeta:
      type: object
      required:
      - current_page
      - per_page
      - total_count
      - total_pages
      - has_next_page
      - has_prev_page
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total_count:
          type: integer
          example: 42
          description: Total ACTIVE TOP-LEVEL comments on the recognition — the rows
            this list pages over. Inlined replies are NOT counted here; the detail
            endpoint's `comments_count` counts every visible row instead.
        total_pages:
          type: integer
          example: 3
        has_next_page:
          type: boolean
          example: true
        has_prev_page:
          type: boolean
          example: false
    RecognitionDetail:
      type: object
      description: ONE recognition rendered for the detail screen — a peer post, an
        award, or an automated certificate. Shared verbatim between /recognitions/posts/{id}
        and /recognitions/awards/{id}; `type` and `kind` say which was resolved.
      required:
      - type
      - kind
      - id
      - engagement
      - permissions
      properties:
        type:
          type: string
          enum:
          - recognition_post
          - award
          description: The persisted model — `recognition_post` or `award`.
        kind:
          type: string
          enum:
          - recognition
          - award
          - certificate
          description: The user-facing kind shown in the feed. `recognition` = peer
            post, `award` = human-given award, `certificate` = automated award.
        id:
          type: integer
          example: 4471
        title:
          type: string
          nullable: true
          example: Great Work
        message:
          type: string
          nullable: true
          description: The recognition body (post content or award description/citation).
          example: You carried the migration all weekend — thank you.
        points:
          type: integer
          example: 40
          description: The recognition's worth in reward points.
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        group_recipients:
          type: array
          nullable: true
          description: Present only for a grouped give (one submission → many recipients).
          items:
            "$ref": "#/components/schemas/RecognitionPersonCard"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          nullable: true
          description: The giver. Null for an anonymous give; "System (Automated)"
            (id null) for an automated certificate.
        anonymous:
          type: boolean
          example: false
        program:
          type: string
          nullable: true
          example: Spot Award
        category:
          type: string
          nullable: true
          example: Teamwork
        company_value:
          type: string
          nullable: true
          description: The main (first) company value's name.
          example: Customer First
        company_values:
          type: array
          description: Every company value it celebrates, primary first — up to three.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Customer First
        can_share_outside:
          type: boolean
          description: Whether the caller may share this outside the company (`POST
            /moment_shares`) — only the person it is about, while it is public and
            the company's Shared moments switch is on, and only with a token holding
            write:recognition.
          example: false
        occurred_at:
          type: string
          format: date-time
          nullable: true
        visibility:
          type: string
          nullable: true
          description: Post visibility (public/team/department/private) or public/private
            for an award.
          example: public
        status:
          type: string
          nullable: true
          example: active
        automated:
          type: boolean
          description: True for a system-generated certificate; always false for a
            post.
          example: false
        tags:
          type: array
          items:
            type: string
          description: Recognition tags (posts only; empty for awards).
        photo_url:
          type: string
          nullable: true
          description: Attached photo (posts only).
        award_art_url:
          type: string
          nullable: true
          description: Award-card art (posts with a card) or the certificate page
            (awards).
        engagement:
          "$ref": "#/components/schemas/RecognitionEngagement"
        boost:
          allOf:
          - "$ref": "#/components/schemas/RecognitionBoost"
          nullable: true
          description: Pile-on points block. Present for posts; null for awards (not
            boostable).
        permissions:
          "$ref": "#/components/schemas/RecognitionPermissions"
    RecognitionQuickAwardForm:
      type: object
      description: 'Everything the Quick Award form renders from. Persona-aware but
        not persona-branched: every reviewer receives the same keys, and what varies
        is the values — `recipient_scope`, the budget, and the recent-awards list.'
      required:
      - enabled
      - require_category
      - recipient_scope
      - amount
      - limits
      - programs
      - budget
      - recent_awards
      properties:
        enabled:
          type: boolean
          description: Always `true` in a 200 — the endpoint answers 403 `feature_disabled`
            otherwise. Reported so one client model covers both answers.
          example: true
        require_category:
          type: boolean
          description: Whether a category is mandatory (tenant setting `quick_award_require_category`).
            Render the required marker from this; the POST enforces it with 422 `category_required`.
          example: false
        recipient_scope:
          type: string
          enum:
          - all
          - direct_reports
          description: Who this caller may award, and therefore the `scope` to call
            `GET /recognitions/employee_suggestions` with. `all` for a Recognitions
            admin; `direct_reports` for a manager — the same rule the POST applies
            to `recipient_id`.
          example: direct_reports
        amount:
          "$ref": "#/components/schemas/RecognitionQuickAwardAmount"
        limits:
          "$ref": "#/components/schemas/RecognitionQuickAwardLimits"
        default_program_id:
          type: integer
          nullable: true
          description: What "Default Program" resolves to, and the program the POST
            funds the award from when `program_id` is omitted — so the `budget` below
            is the budget a default award actually draws down. Null only when the
            tenant has no active program at all, in which case the POST answers 422
            `no_program`.
          example: 7
        programs:
          type: array
          description: Active, in-window programs in name order — the web picker's
            own list. Empty when the tenant has none.
          items:
            "$ref": "#/components/schemas/RecognitionQuickAwardProgram"
        budget:
          "$ref": "#/components/schemas/RecognitionQuickAwardBudget"
        recent_awards:
          type: array
          description: 'This manager''s most recent awards in THIS tenant, newest
            first, capped at 5 — the web sidebar''s confirmation list. Finalized only:
            revoked and expired awards are excluded, matching every count of "recognition
            given". Deliberately smaller than a feed card; the full card is at `GET
            /recognitions/awards/{id}`.'
          items:
            "$ref": "#/components/schemas/RecognitionQuickAwardRecent"
    RecognitionQuickAwardAmount:
      type: object
      description: The amount input's bounds, **all in reward points**. The columns
        store dollars (1 pt = 1¢); the server converts both ways, so a client never
        handles dollars.
      required:
      - min_points
      - max_points
      - default_points
      - presets
      - points_per_dollar
      properties:
        min_points:
          type: integer
          description: The floor. A zero or negative amount is refused `invalid_amount`.
          example: 1
        max_points:
          type: integer
          description: The tenant's per-award cap (`quick_award_max_amount`). Never
            widen the input past this — the POST refuses it with `amount_over_limit`.
            A program's own `per_award_limit_points` and a category's range can be
            tighter still.
          example: 10000
        default_points:
          type: integer
          description: What to pre-fill (`quick_award_default_amount`), already clamped
            to `max_points` so it is never an amount the server would refuse.
          example: 2500
        presets:
          type: array
          description: The web "Quick select" ladder, already filtered to the cap
            — offered so a phone renders the same one-tap amounts instead of inventing
            its own. Falls back to `[max_points]` for a tenant whose cap is below
            the smallest rung.
          items:
            type: integer
          example:
          - 10
          - 25
          - 50
          - 100
          - 150
          - 200
          - 250
          - 500
        points_per_dollar:
          type: integer
          description: The tenant's conversion rate, for the rare screen that shows
            a currency figure. Everything else on this wire is points.
          example: 100
    RecognitionQuickAwardLimits:
      type: object
      description: What the two text fields will actually accept, read from the constants
        the `Award` validations are written from. Both fields are optional — the server
        substitutes a default when either is blank — but a value that IS sent has
        to clear these.
      required:
      - title_min
      - title_max
      - message_min
      - message_max
      properties:
        title_min:
          type: integer
          example: 2
        title_max:
          type: integer
          example: 100
        message_min:
          type: integer
          description: 'The one that surprises callers: a note that is sent must be
            at least this long, or the POST answers 422 `invalid`. Render a counter
            from it.'
          example: 10
        message_max:
          type: integer
          example: 1000
    RecognitionQuickAwardProgram:
      type: object
      description: One program in the picker, with its own categories.
      required:
      - id
      - name
      - categories
      properties:
        id:
          type: integer
          example: 7
        name:
          type: string
          example: Spot Awards
        slug:
          type: string
          example: spot-awards
        description:
          type: string
          nullable: true
          example: On-the-spot recognition for going above and beyond.
        program_type:
          type: string
          example: manager_to_employee
        program_type_label:
          type: string
          nullable: true
          description: The human label for `program_type`, as the web select shows
            it.
          example: Manager to Employee
        per_award_limit_points:
          type: integer
          nullable: true
          description: This program's own ceiling on a SINGLE award, in points. Null
            means no program cap — `amount.max_points` still applies. Bound the input
            by whichever is tighter, or the POST answers `amount_over_limit`.
          example: 5000
        categories:
          type: array
          description: Only this program's ACTIVE categories, in the picker's order.
            Empty when the program has none, in which case the category field has
            nothing to offer — a tenant that also sets `require_category` has a misconfiguration
            an admin has to fix.
          items:
            "$ref": "#/components/schemas/RecognitionQuickAwardCategory"
    RecognitionQuickAwardCategory:
      type: object
      description: A category chip and its value range, in points. `null` on a bound
        means unbounded on that side — which is a different answer from a bound of
        zero.
      required:
      - id
      - name
      properties:
        id:
          type: integer
          example: 31
        name:
          type: string
          example: Teamwork
        slug:
          type: string
          example: teamwork
        icon:
          type: string
          description: Icon name, falling back to the same default the web chip draws,
            so an unconfigured category never renders blank.
          example: handshake
        color:
          type: string
          description: Hex colour, with the same web fallback.
          example: "#112233"
        min_points:
          type: integer
          nullable: true
          description: Lowest amount this category accepts; null when unbounded.
          example: 100
        max_points:
          type: integer
          nullable: true
          description: Highest amount this category accepts; null when unbounded.
          example: 800
        default_points:
          type: integer
          nullable: true
          description: What to re-fill the amount with when this category is picked
            — what the web form does. Null when the category sets no default.
          example: 400
    RecognitionQuickAwardBudget:
      type: object
      description: 'The manager''s giving budget for the default program, every figure
        in points. `{ "enabled": false }` for most tenants — businesses without group
        budgets are unaffected by any of this — so branch on `enabled` before reading
        anything else.'
      required:
      - enabled
      properties:
        enabled:
          type: boolean
          description: Whether a group budget applies to this manager at all.
          example: true
        name:
          type: string
          nullable: true
          example: FY26 Engineering Recognition
        status:
          type: string
          nullable: true
          example: active
        exhausted:
          type: boolean
          description: The budget itself is spent out.
          example: false
        blocked:
          type: boolean
          description: The web submit button's own disabled condition — exhausted,
            OR this manager has nothing left. Disable Give on this rather than re-deriving
            it.
          example: false
        your_remaining_points:
          type: integer
          description: What THIS manager may still give from it.
          example: 42000
        your_spent_points:
          type: integer
          example: 8000
        max_per_manager_points:
          type: integer
          nullable: true
          description: The per-manager cap, or null when the budget sets none.
          example: 50000
        available_total_points:
          type: integer
          description: What the whole budget has left, across every manager on it.
          example: 310000
        annual_allocation_points:
          type: integer
          nullable: true
          example: 500000
    RecognitionQuickAwardRecent:
      type: object
      description: One row of the "Your Recent Awards" sidebar.
      properties:
        id:
          type: integer
          example: 9182
        title:
          type: string
          example: Shipped the migration
        points:
          type: integer
          description: The award's value in points.
          example: 500
        awarded_at:
          type: string
          format: date-time
          example: '2026-08-17T14:05:00Z'
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
    RecognitionQuickAwardRequest:
      type: object
      description: The Quick Award form's submission. `recipient_id` and `amount`
        are the only required fields — the minimum the native sheet collects; everything
        else has a documented server-side default.
      required:
      - recipient_id
      - amount
      properties:
        recipient_id:
          type: integer
          description: Who is being awarded. Must be one of this caller's direct reports
            unless they are a Recognitions admin, who may award anyone — see `recipient_scope`
            on the GET, and fill the picker from `GET /recognitions/employee_suggestions`.
          example: 412
        amount:
          type: integer
          description: The award, in reward POINTS. Within `amount.min_points` ..
            `amount.max_points`, and also within the chosen program's `per_award_limit_points`
            and the chosen category's range when either is set.
          example: 500
        program_id:
          type: integer
          nullable: true
          description: Which program funds it. Omit for the tenant's `default_program_id`.
            A program that is not active in this tenant is refused with `invalid_program`,
            never swapped for the default.
          example: 7
        category_id:
          type: integer
          nullable: true
          description: A category **of the chosen program**. An id belonging to a
            different program is silently ignored rather than refused, matching the
            web form where a category can only be picked after its program. Required
            when `require_category` is true.
          example: 31
        title:
          type: string
          nullable: true
          description: A short headline. Blank becomes `"Quick Award from {giver name}"`;
            a value that is sent must fit `limits.title_min`..`title_max`.
          example: Shipped the migration
        message:
          type: string
          nullable: true
          description: The personal note. Blank becomes `"Great work! Keep it up."`;
            a value that is sent must clear `limits.message_min` (10 characters) or
            the request is refused `invalid`.
          example: You carried the migration all weekend — thank you.
        is_public:
          type: boolean
          default: true
          description: Whether the award appears in the recognition feed and on any
            connected Slack/Teams recognition channel. **Defaults to true**, because
            the web checkbox is pre-checked — send `false` explicitly to keep it private.
          example: true
        anniversary_years:
          type: integer
          nullable: true
          description: |-
            Set this ONLY when the award is the recipient's work-anniversary award — the **Recognize** action on an anniversary row. Echo that row's own `years` from `GET /recognitions/anniversary_roster` (or `upcoming_anniversaries[].years` on `GET /recognitions/team`).

            With it, the award is RECORDED as that anniversary's recognition: the row comes back `recognized: true`, `status: "recognized"` and `can_recognize: false`, the roster's Recognized tile counts it, **Mark reward as sent** becomes available, and the automatic anniversary award is not given a second time. Without it the points still land but nothing is recorded, so the row keeps offering Recognize after every refresh.

            Omit it for an ordinary spot award — an inferred anniversary would cancel that employee's automatic award for the year. A value that is not one of this person's real anniversaries (the one they are heading into, or the one already passed this calendar year) is ignored; the award itself still succeeds.
          example: 10
    RecognitionQuickAwardCard:
      type: object
      description: 'The created award as the FEED renders it — the same card `GET
        /recognitions/feed` and `GET /recognitions/awards/{id}` return, so a client
        can insert it optimistically and get the same shape back on the next refresh.
        A Quick Award is never pending: a 201 means it is live.'
      properties:
        type:
          type: string
          enum:
          - award
          example: award
        kind:
          type: string
          enum:
          - award
          description: Always `award`, never `certificate` — a certificate is an automated
            lifecycle award, and a Quick Award is by definition given by a person.
          example: award
        id:
          type: integer
          example: 9182
        title:
          type: string
          example: Shipped the migration
        message:
          type: string
          nullable: true
          description: The award's description — the note the giver wrote.
          example: You carried the migration all weekend — thank you.
        points:
          type: integer
          example: 500
        recipient:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        giver:
          "$ref": "#/components/schemas/RecognitionPersonCard"
        anonymous:
          type: boolean
          description: Always false — a Quick Award is attributed to its giver.
          example: false
        automated:
          type: boolean
          example: false
        program:
          type: string
          nullable: true
          example: Spot Awards
        category:
          type: string
          nullable: true
          example: Teamwork
        visibility:
          type: string
          enum:
          - public
          - private
          description: Derived from `is_public`.
          example: public
        status:
          type: string
          enum:
          - active
          - revoked
          - expired
          example: active
        certificate_url:
          type: string
          nullable: true
          description: Absolute URL of the printable gold-framed certificate page
            for this award.
          example: https://acme.workforce.mangoapps.com/recognition/awards/9182/card
        tags:
          type: array
          description: Always empty — tags are a peer-recognition field, not an award
            one.
          items:
            type: string
          example: []
        boost:
          nullable: true
          description: Always null — awards cannot be boosted; only peer gives can.
        occurred_at:
          type: string
          format: date-time
          example: '2026-08-17T14:05:00Z'
    RecognitionQuickAwardRefusal:
      type: object
      description: A 403 from either Quick Award verb.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - feature_disabled
              - forbidden
              description: "`access_denied` — no access to the Recognitions app. `feature_disabled`
                — the tenant switched Quick Awards off; hide the affordance. `forbidden`
                — the caller is not a reviewer."
            message:
              type: string
    RecognitionQuickAwardError:
      type: object
      description: A refused give. Nothing was written. `code` names the rule so a
        client can highlight the offending field; `message` is the wording the web
        flash shows and is safe to display as-is.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - invalid_recipient
              - recipient_not_permitted
              - invalid_amount
              - amount_over_limit
              - category_required
              - governance_blocked
              - budget_exceeded
              - invalid_program
              - no_program
              - invalid
              - internal_error
              example: amount_over_limit
            message:
              type: string
              example: Award amount cannot exceed $100.00.
            details:
              type: object
              nullable: true
              description: Present on the amount refusals only — the bounds the amount
                was measured against, so a client can correct the field rather than
                asking the user to guess.
              properties:
                min_points:
                  type: integer
                  example: 1
                max_points:
                  type: integer
                  example: 10000
    RecognitionEmployeeSuggestion:
      type: object
      description: 'One suggestible recipient. The shared Recognitions person card
        plus the two fields a recipient picker needs, and which the web picker already
        shows: `email` (the disambiguator — "Chris Taylor" is two people in most tenants,
        and a picker that can''t tell them apart sends recognition to the wrong one)
        and `department` (the row''s secondary line).'
      properties:
        id:
          type: integer
          example: 412
        name:
          type: string
          example: Maya Chen
        title:
          type: string
          nullable: true
          description: Job title, or null when unset.
          example: Support Specialist
        image:
          type: string
          nullable: true
          description: Absolute avatar URL, or null when it can't be resolved.
        email:
          type: string
          nullable: true
          example: maya.chen@example.com
        department:
          type: string
          nullable: true
          description: Primary organizational department name, or null.
          example: Customer Support
        detail:
          type: string
          description: 'What tells this person apart from a colleague with the same
            name: their job title, then (only when two people in the list share a
            name) their location, department or Employee ID, joined by " · ". Each
            fact only as far as the People directory shows it to the caller; absent
            when there is nothing to show.'
          example: Support Specialist · Denver
    RecognitionEmployeeSuggestionsMeta:
      type: object
      description: The page envelope every paginated Recognitions endpoint answers
        in, plus the three fields a picker needs to render the right empty state and
        the right scope copy.
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total_count:
          type: integer
          description: Total suggestible colleagues matching this search and scope.
          example: 148
        total_pages:
          type: integer
          example: 6
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
        query:
          type: string
          nullable: true
          description: The search term as applied (trimmed; null when blank). Echoed
            so a client can tell "nobody matches Xyz" from "you have no colleagues
            to recognize" and render the right empty state for each.
          example: maya
        scope:
          type: string
          enum:
          - all
          - direct_reports
          description: The scope as applied — `all` when an unrecognized value was
            sent.
        direct_reports_only:
          type: boolean
          description: True when the roster was actually narrowed to this caller's
            direct reports, i.e. `scope=direct_reports` AND the caller is not an admin
            (an admin may Quick Award anyone, so nothing is narrowed for them). Read
            THIS, not the `scope` you sent, before showing "your team only" copy.
    RecognitionPersonCard:
      type: object
      description: The shared person card used across the Recognitions API.
      properties:
        id:
          type: integer
          nullable: true
          example: 42
        name:
          type: string
          example: Maya Chen
        title:
          type: string
          nullable: true
          example: Staff Engineer
        image:
          type: string
          nullable: true
          description: Absolute avatar URL.
    RecognitionEngagement:
      type: object
      description: Reaction and comment engagement, including the full list of reactions.
      required:
      - reactions_count
      - comments_count
      - reactions
      properties:
        reactions_count:
          type: integer
          example: 3
        reactions_enabled:
          type: boolean
          example: true
        my_reaction:
          type: string
          nullable: true
          description: The viewer's own reaction emoji, if any.
          example: "\U0001F389"
        reaction_summary:
          type: array
          description: Reactions grouped by emoji, most-used first.
          items:
            type: object
            properties:
              emoji:
                type: string
                example: "\U0001F44D"
              count:
                type: integer
                example: 2
        reactions:
          type: array
          description: Every reaction on this recognition, oldest first — emoji plus
            who left it.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 55012
              emoji:
                type: string
                example: "\U0001F44D"
              label:
                type: string
                example: Like
              user:
                "$ref": "#/components/schemas/RecognitionPersonCard"
              reacted_at:
                type: string
                format: date-time
        comments_count:
          type: integer
          example: 2
        comments_enabled:
          type: boolean
          example: true
        shares_count:
          type: integer
          example: 0
          description: Always 0 for awards.
    RecognitionBoost:
      type: object
      description: Peer pile-on points (posts only).
      properties:
        total_points:
          type: integer
          example: 25
          description: Points added by boosters so far.
        boosted_by_me:
          type: boolean
          example: false
        can_boost:
          type: boolean
          example: true
        amounts:
          type: array
          items:
            type: integer
          description: The boost amounts the viewer may give, capped to their remaining
            allowance.
          example:
          - 5
          - 10
          - 25
    RecognitionBoostReceipt:
      type: object
      description: The boost that was just applied, returned by `POST /recognitions/posts/{id}/boost`.
        The points have already left the booster's allowance and landed in the recipient's
        store balance — this is a receipt, not a pending request.
      required:
      - id
      - points
      - booster
      - recipient
      properties:
        id:
          type: integer
          example: 812
        points:
          type: integer
          enum:
          - 5
          - 10
          - 25
          example: 10
        boosted_at:
          type: string
          format: date-time
        booster:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          description: The caller — the person whose allowance paid for this boost.
        recipient:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPersonCard"
          description: The recognition's recipient — the person whose balance was
            credited.
    RecognitionBoostTarget:
      type: object
      description: The boosted post's refreshed state, so a client can patch the card
        it already has rather than re-fetching the feed or the detail screen. The
        `boost` block is the SAME shape those two endpoints return.
      required:
      - id
      - type
      - boost
      properties:
        id:
          type: integer
          example: 4471
        type:
          type: string
          enum:
          - recognition_post
          description: Always `recognition_post` — awards are not boostable.
        boost:
          allOf:
          - "$ref": "#/components/schemas/RecognitionBoost"
          description: Recomputed after the write, so `boosted_by_me` is true, `can_boost`
            is false (one boost per person per post) and `amounts` is empty.
    RecognitionBoostError:
      type: object
      description: A refused boost. `details` carries the post's CURRENT boost block
        and the caller's remaining allowance, so a client whose card was stale can
        reconcile from the refusal instead of re-fetching the screen.
      required:
      - error
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
              enum:
              - invalid_boost
              - insufficient_allowance
              - boost_failed
              example: insufficient_allowance
            message:
              type: string
              description: Safe to show to the user verbatim — the same wording the
                web flashes.
              example: You only have 5 giving points left this month.
            details:
              type: object
              properties:
                boost:
                  "$ref": "#/components/schemas/RecognitionBoost"
                giving_remaining:
                  type: integer
                  description: The caller's remaining monthly giving allowance (unchanged
                    — a refusal spends nothing).
                  example: 5
    RecognitionPermissions:
      type: object
      description: Per-viewer capability flags — the affordances the server would
        honour.
      required:
      - can_edit
      - can_delete
      - can_boost
      properties:
        can_edit:
          type: boolean
          description: 'May PATCH this recognition''s message/company value: the author
            or a moderator on a post, a moderator on an award, and only while it is
            still active.'
        can_delete:
          type: boolean
          description: Author/admin delete for a post; admin revoke for an award.
        can_boost:
          type: boolean
          description: Peer pile-on eligibility; always false for awards.
        can_share:
          type: boolean
        can_comment:
          type: boolean
        can_react:
          type: boolean
    WrappedPersonCount:
      type: object
      description: A person named in a Recognition Wrapped list, plus how many recognitions
        were exchanged with them.
      required:
      - user
      - count
      properties:
        user:
          type: object
          description: The shared person card used across the Recognitions API.
          properties:
            id:
              type: integer
              example: 42
            name:
              type: string
              example: Maya Chen
            title:
              type: string
              nullable: true
              example: Engineering Manager
            image:
              type: string
              nullable: true
              description: Absolute avatar URL
              or null.:
              example: https://cdn.example.com/avatars/42.jpg
        count:
          type: integer
          description: Number of recognitions exchanged with this person this year.
          example: 4
    RecognitionBulkReviewResult:
      type: object
      description: 'The outcome of a bulk approve/reject. A 200 means the batch was
        accepted; it may still carry skipped or errored rows, so read the counts.
        The three lists partition the requested ids: `processed` succeeded, `skipped_ids`
        were not acted on (not reviewable / already decided / another tenant''s),
        and `errors` failed mid-write.'
      required:
      - action
      - requested_count
      - processed_count
      - skipped_count
      - error_count
      - processed
      - skipped_ids
      - errors
      properties:
        action:
          type: string
          enum:
          - approve
          - reject
          example: approve
        message:
          type: string
          description: A human-readable summary, mirroring the web flash.
          example: 3 nominations approved.
        requested_count:
          type: integer
          description: How many distinct ids the caller submitted (after de-duplication).
          example: 5
        processed_count:
          type: integer
          example: 3
        skipped_count:
          type: integer
          example: 2
        error_count:
          type: integer
          example: 0
        processed:
          type: array
          description: The nominations that were approved/rejected.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 8123
              nominee:
                "$ref": "#/components/schemas/RecognitionPerson"
              status:
                type: string
                enum:
                - pending
                - under_review
                - approved
                - rejected
                - cancelled
                example: approved
              finalized:
                type: boolean
                description: True once terminal (award minted / rejected); false while
                  a multi-level chain is still advancing.
                example: true
              approval_level:
                type: integer
                description: Which approval step is current, of how many.
                example: 1
              approval_levels_required:
                type: integer
                example: 1
        skipped_ids:
          type: array
          description: Requested ids that were NOT acted on — not reviewable by the
            caller, already decided, or another tenant's.
          items:
            type: integer
          example:
          - 8140
          - 8141
        errors:
          type: array
          description: Per-row failures. Each names the nominee and the reason the
            write raised.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 8130
              name:
                type: string
                example: Ada Lovelace
              message:
                type: string
                example: 'Validation failed: Approved value must be greater than or
                  equal to 0'
        unread_notification_count:
          type: integer
          description: The caller's unread notification count, for the app badge.
          example: 3
    RecognitionNominationInput:
      type: object
      description: The nominate composer's fields — the same set the web form (`recognition/nominate.html.erb`)
        submits. Build the pickers from `GET /recognitions/config`.
      required:
      - recognition_program_id
      - nominee_id
      - title
      - description
      properties:
        recognition_program_id:
          type: integer
          description: The award being nominated for. Must be a program that is nominatable
            right now AND open to this caller — `config.programs[]` reports both as
            `can_nominate`.
          example: 41
        nominee_id:
          type: integer
          description: 'The colleague being nominated. Must be a member of the caller''s
            business, and cannot be the caller. Picker: `GET /recognitions/employee_suggestions`.'
          example: 9021
        title:
          type: string
          description: A short award-worthy headline. Bounds are `config.limits.nomination_title_*`.
          minLength: 2
          maxLength: 100
          example: Renewal save of the quarter
        description:
          type: string
          description: What the nominee did that deserves recognition. Bounds are
            `config.limits.nomination_description_*`.
          minLength: 10
          maxLength: 1000
          example: Turned a churn risk into a multi-year renewal.
        recognition_category_id:
          type: integer
          nullable: true
          description: Optional. Must belong to the chosen program (`config.programs[].categories`).
            Its `min_points` / `max_points` bound `requested_value`.
          example: 77
        justification:
          type: string
          nullable: true
          description: Optional supporting detail — the measurable impact. Cap is
            `config.limits.nomination_justification_max`.
          maxLength: 2000
          example: Cut renewal turnaround from 5 days to 1 across 40 accounts.
        requested_value:
          type: integer
          nullable: true
          description: Optional suggested award value in reward POINTS (converted
            server-side to stored dollars at `config.economy.points_per_dollar`).
            Must be zero or greater, and within the chosen category's range and the
            program's per-award cap.
          example: 250
        is_public:
          type: boolean
          nullable: true
          description: Whether the nomination appears in the recognition feed. OMITTED
            falls back to the TENANT's configured default visibility, not to `true`.
          example: true
        is_anonymous:
          type: boolean
          nullable: true
          description: Hide the nominator's name. Silently forced to `false` while
            the tenant has anonymous recognition switched off (`config.permissions`
            / `visibility_options` report whether it is offered), exactly as the web
            form hides the checkbox.
          example: false
    RecognitionNominationDecision:
      type: object
      description: One nomination row — the shared card that both reviewer decisions
        (approve/reject) and the file endpoint (`POST /recognitions/nominations`)
        answer with, so a client keys off ONE shape across the whole nomination lifecycle.
        `reviewer_notes` carries the decision note (the approval note after approve,
        the rejection reason after reject) and is null on a freshly filed nomination;
        `approval_complete` is true once the request reaches a terminal state (award
        minted, or rejected) and false while a multi-level chain is still advancing
        — a nomination filed into an approval-off tenant is born complete.
      properties:
        type:
          type: string
          description: Always `nomination`, so a client can key off one row shape.
          example: nomination
        id:
          type: integer
          example: 8123
        title:
          type: string
          example: Renewal save of the quarter
        status:
          type: string
          enum:
          - pending
          - under_review
          - approved
          - rejected
          - cancelled
          example: approved
        display_status:
          type: string
          example: Approved
        status_color:
          type: string
          description: The hex the web badge is painted with, so both surfaces agree
            on the status colour.
          example: "#16a34a"
        approval_complete:
          type: boolean
          description: True once the request is terminal (award minted / rejected);
            false while advancing a multi-level chain.
          example: true
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          "$ref": "#/components/schemas/RecognitionPerson"
        reviewer:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: The deciding reviewer, or the NEXT approver on a multi-level
            advance.
        program:
          type: string
          nullable: true
          example: Spot Awards
        category:
          type: string
          nullable: true
          example: Customer Impact
        approval_level:
          type: integer
          description: Which approval step is current, of how many.
          example: 1
        approval_levels_required:
          type: integer
          example: 1
        points:
          type: integer
          description: What the nomination is worth right now, in reward POINTS —
            the approved figure once a reviewer has set one, the requested figure
            until then. The same key (and unit) the My Recognition rows carry, so
            one card component renders a nomination wherever it meets one. 0 when
            it was filed without a value.
          example: 250
        requested_value:
          type: string
          nullable: true
          description: The originally requested award value, in stored DOLLARS, as
            a decimal string. Use `requested_points` unless you specifically mean
            money.
          example: '2.5'
        approved_value:
          type: string
          nullable: true
          description: The approved award value, in stored dollars (points ÷ points-per-dollar),
            as a decimal string. Use `approved_points` unless you specifically mean
            money.
          example: '5.0'
        requested_points:
          type: integer
          nullable: true
          description: "`requested_value` in reward points. Null (not 0) when no value
            was asked for."
          example: 250
        approved_points:
          type: integer
          nullable: true
          description: "`approved_value` in reward points. Null until the nomination
            is approved."
          example: 500
        reviewer_notes:
          type: string
          nullable: true
          description: The decision note — the approval note, or the rejection reason.
          example: Clear, measurable impact on the renewal — well earned.
        submitted_at:
          type: string
          format: date-time
          nullable: true
        reviewed_at:
          type: string
          format: date-time
          nullable: true
    RecognitionAwardCycleBase:
      type: object
      description: The fields EVERY award-cycle card carries, whichever list it is
        in, so a client reads one shape and the four lists stay comparable.
      required:
      - id
      - name
      - status
      - status_label
      - selection_mode
      - opens_at
      - closes_at
      - award_points
      - nomination_count
      - nominee_count
      properties:
        id:
          type: integer
          example: 6
        name:
          type: string
          example: Employee of the Quarter — Q3
        description:
          type: string
          nullable: true
          example: Our monthly peer-nominated award for people who make the work better
            for everyone.
        criteria:
          type: string
          nullable: true
          description: '"Looking for:" on the web card — what a good nomination looks
            like. Show it in the nominate form; it is the whole point of a criteria-led
            award.'
          example: Someone who went out of their way to help a teammate or a customer
            this month.
        status:
          type: string
          enum:
          - scheduled
          - open
          - closed
          - decided
          description: The EFFECTIVE status, which reconciles the stored column against
            the live window — not the stored `status`. This is what decides the list
            the card is in. Never re-derive it from the timestamps.
          example: open
        status_label:
          type: string
          description: The pill the web card paints next to the title.
          enum:
          - Opens soon
          - Open
          - Awaiting decision
          - Winners announced
          example: Open
        status_badge_class:
          type: string
          description: The Bootstrap class the web pill uses, so a native pill can
            carry the same treatment without maintaining its own mapping.
          example: bg-success-subtle text-success-emphasis
        selection_mode:
          type: string
          enum:
          - spotlight
          - committee
          description: "`spotlight` recognizes everyone meeting the distinct-nominator
            threshold; `committee` is decided by a named committee after the window
            closes."
          example: spotlight
        selection_mode_label:
          type: string
          enum:
          - Spotlight
          - Committee
          example: Spotlight
        opens_at:
          type: string
          format: date-time
        closes_at:
          type: string
          format: date-time
        recurrence:
          type: string
          enum:
          - none
          - weekly
          - monthly
          - quarterly
          example: monthly
        recurring:
          type: boolean
          description: Whether the next cycle is created automatically when this one
            is decided.
          example: true
        award_points:
          type: integer
          description: The prize, in the reward POINTS employees see everywhere else
            in this app. 0 means recognition only — no points attached.
          example: 500
        program:
          type: object
          nullable: true
          description: The recognition program this cycle runs under.
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Spot Recognition
        category:
          type: object
          nullable: true
          description: Null when the cycle isn't scoped to one category.
          properties:
            id:
              type: integer
              example: 1
            name:
              type: string
              example: Teamwork
        nomination_count:
          type: integer
          description: Total nominations pooled in this cycle.
          example: 7
        nominee_count:
          type: integer
          description: DISTINCT people nominated — the size of the pool a committee
            actually decides between. Never greater than `nomination_count`; 7 nominations
            from 3 nominees is a different job than 7 from 7.
          example: 3
        icon:
          type: string
          description: Cycles carry no icon of their own; every surface uses the trophy.
            Same glyph the dashboard's running-cycle banner sends.
          example: fas fa-trophy
    RecognitionAwardCycleOpenCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: An open cycle — the hero card, and the only list with a CTA.
        required:
        - days_left
        - closing_soon
        - can_nominate
        - nominated_by_me
        - my_nomination_count
        properties:
          days_left:
            type: integer
            description: Whole days until nominations close, floor 0 — the same arithmetic
              the dashboard banner uses, so the two screens never differ by a day
              for the same cycle.
            example: 8
          closing_soon:
            type: boolean
            description: True when `days_left` is within `meta.closing_soon_days`.
            example: false
          threshold_nominators:
            type: integer
            nullable: true
            description: How many DISTINCT nominators a nominee needs before a Spotlight
              cycle recognizes them. **Null for a committee cycle** — the committee
              decides, so a threshold is not what a nominator should aim at.
            example: 3
          can_nominate:
            type: boolean
            description: Whether this caller may nominate RIGHT NOW — the same answer
              the submit path gives. Hide the Nominate affordance when false.
            example: true
          nomination_block_reason:
            type: string
            nullable: true
            description: Why not, in the caller's words. Null when they can. Show
              this instead of a bare disabled button.
            example: Peer recognition is not enabled
          nominated_by_me:
            type: boolean
            description: Whether this caller has already nominated into this cycle.
              STATE, not a gate — a cycle pools nominations, so nominating again is
              allowed. Use it to say "you've nominated" rather than re-offering a
              fresh CTA with no memory.
            example: false
          my_nomination_count:
            type: integer
            description: How many nominations this caller filed here. Never exceeds
              `nomination_count`.
            example: 0
          nominate_url:
            type: string
            description: Absolute URL of the WEB nominate form (a native client can't
              resolve a relative path). Still sent because a WebView client uses it;
              a native client wants the two below.
            example: https://acme.workforce.mangoapps.com/recognition/award-cycles/6/nominate
          nominate_form_api_url:
            type: string
            description: 'Absolute URL of the NATIVE composer — `GET /recognitions/award_cycles/{id}/nominations/new`.
              Present even when `can_nominate` is false: the two answer different
              questions, so hide the CTA on `can_nominate` (showing `nomination_block_reason`),
              never on the absence of a URL.'
            example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/6/nominations/new
          nominate_api_url:
            type: string
            description: Absolute URL to POST the nomination to — `POST /recognitions/award_cycles/{id}/nominations`.
              Sent so a client that got this card from the browse list never rebuilds
              the path.
            example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/6/nominations
    RecognitionAwardCycleUpcomingCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: A scheduled cycle. Deliberately thinner than an open card — there
          is no CTA, because the window hasn't opened and the submit path would refuse.
        required:
        - opens_in_days
        - can_nominate
        properties:
          opens_in_days:
            type: integer
            description: Whole days until the window opens, floor 0.
            example: 12
          can_nominate:
            type: boolean
            description: Always false in this list.
            example: false
          nomination_block_reason:
            type: string
            example: This award isn't open for nominations right now.
    RecognitionAwardCycleReviewCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: A closed committee cycle THIS caller is asked to decide. Everything
          in this list already passed the membership + recusal test.
        required:
        - can_review
        - my_pick_count
        properties:
          can_review:
            type: boolean
            description: The wider controller gate, which ALSO admits an admin who
              isn't on the committee. True by construction for anything in `needs_my_review`;
              present so a client reads the same key whether it got the cycle from
              this list or built the link itself.
            example: true
          my_pick_count:
            type: integer
            description: How many nominees this member has already recommended — so
              the prompt can distinguish "you haven't started" from "you've submitted
              picks".
            example: 1
          review_url:
            type: string
            description: Absolute URL of the anonymized committee review page.
            example: https://acme.workforce.mangoapps.com/recognition/award-cycles/10/review
    RecognitionAwardCycleDecidedCard:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleBase"
      - type: object
        description: A decided cycle — the "Recent winners" row, expanded with everything
          the web results page shows, so a client can render the row AND its results
          screen from this one response.
        required:
        - decided_at
        - winners
        - winner_count
        - winner_names
        - honor_roll
        - honor_roll_count
        properties:
          decided_at:
            type: string
            format: date-time
            description: When the winners were announced — the date under the trophy
              hero.
          announcement_published:
            type: boolean
            description: Whether the company-wide winner announcement was actually
              published (a decide in "draft" mode leaves it for an admin), so a client
              doesn't imply a broadcast that never went out.
            example: true
          winners:
            type: array
            description: Only the people who actually won. Empty when nobody met the
              bar — a real outcome for a Spotlight cycle whose threshold nobody reached.
            items:
              "$ref": "#/components/schemas/RecognitionAwardCycleWinner"
          winner_count:
            type: integer
            example: 2
          winner_names:
            type: array
            description: The names in display order, for a client that renders the
              web row's one-line sentence ("Alice and Bob"). Always parallel to `winners`.
            items:
              type: string
            example:
            - J.Quack Kolb
            - Weston D'Amore
          honor_roll:
            type: array
            description: EVERYONE who was nominated, winners included — "being nominated
              by a colleague is itself recognition". Never larger than `nominee_count`.
            items:
              "$ref": "#/components/schemas/RecognitionPerson"
          honor_roll_count:
            type: integer
            example: 3
          results_url:
            type: string
            description: Absolute URL of the public results / honor-roll page.
            example: https://acme.workforce.mangoapps.com/recognition/award-cycles/11/results
    RecognitionAwardCycleWinner:
      type: object
      description: One winner of a decided cycle, with the certificate to show them.
      required:
      - user
      - label
      - title
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        label:
          type: string
          description: The caption under the name on the web winner card.
          example: Winner
        title:
          type: string
          description: The award's own title, falling back to "<cycle> — Winner" when
            no Award record exists.
          example: Employee of the Quarter — Q2 — Winner
        certificate:
          allOf:
          - "$ref": "#/components/schemas/RecognitionAwardCycleCertificate"
          nullable: true
          description: "**Nullable.** The decide step logs and continues when an Award
            can't be minted, so a winner with no certificate is a real state. Handle
            the null rather than assuming one is always present."
        can_share_outside:
          type: boolean
          description: Whether the caller (the winner) may share this award outside
            the company (`POST /moment_shares`, subject_type `award`, subject_id `certificate.award_id`)
            — as the web results page offers them Share.
          example: false
    RecognitionAwardCycleCertificate:
      type: object
      description: |-
        The printable certificate's fields, in the order the printed page renders them: title, the recipient, who awarded it, the italic citation quote, the program / points / company-value chips, then the footer's issuing organization and date.

        **One block, two endpoints.** This is exactly what `GET /recognitions/awards/{id}/certificate` returns standalone, and what a decided cycle's `winners[].certificate` embeds. Both are produced by one serializer, so a client can render the "View Certificate" overlay from either source and can never get two different certificates for one award.
      required:
      - award_id
      - title
      - recipient
      - points
      - chips
      - unit
      - certificate_url
      - share_url
      properties:
        award_id:
          type: integer
          example: 23
        title:
          type: string
          example: Employee of the Quarter — Q2 — Winner
        recipient:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: The person the certificate is presented to. Carried on the
            block itself — not only on the enclosing winner card — so a client holding
            just this certificate (from a QR scan or a shared link) can render the
            whole thing from it.
        citation:
          type: string
          nullable: true
          description: The italic quote on the certificate — the reason this person
            won.
          example: Recognized through Employee of the Quarter — Q2.
        awarded_by:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: '**Null when the award was anonymous** — naming the giver is
            exactly the disclosure they opted out of. Automated awards present as
            a "System (Automated)" principal with a null id rather than leaking the
            system account.'
        program:
          type: string
          nullable: true
          example: Spot Recognition
        category:
          type: string
          nullable: true
          example: Teamwork
        company_value:
          type: string
          nullable: true
          example: Teamwork First
        company_values:
          type: array
          description: Every company value it celebrates, primary first — up to three.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Customer First
        points:
          type: integer
          description: The reward points on the certificate chip. The economy is STORED
            in dollars and PRESENTED in points (1 point = 1¢); this is the already-converted
            figure.
          example: 500
        points_label:
          type: string
          nullable: true
          description: The points chip's label, delimited exactly as the printed certificate
            renders it. **Null when the award carries no points** — the web view prints
            the coins badge only for a positive value, so a recognition-only award
            must not show a "0 pts" chip.
          example: 500 pts
        chips:
          type: array
          description: The certificate's badges, pre-ordered and pre-glyphed as the
            web page paints them (program → points → one `company_value` chip per
            value the award celebrates, primary first), so the client keeps no mapping
            of its own. Each appears exactly when its source field does; the array
            is empty for an award with no program, points or value tag.
          items:
            type: object
            required:
            - key
            - label
            - icon
            properties:
              key:
                type: string
                enum:
                - program
                - points
                - company_value
                example: program
              label:
                type: string
                example: Employee of the Quarter
              icon:
                type: string
                description: Font Awesome class, the same glyph the web badge uses.
                example: fas fa-trophy
        unit:
          type: string
          description: The issuing organization, as the certificate footer prints
            it ("unit · date").
          example: OfficeChat
        awarded_at:
          type: string
          format: date-time
        awarded_on:
          type: string
          description: The award date, pre-formatted as the printed certificate shows
            it.
          example: August 13, 2026
        certificate_url:
          type: string
          description: 'Absolute URL of the **printable** certificate — the print-layout
            page behind Print / Save. (No QR is rendered on it: a scan-to-view square
            reads fine on digital signage but out of place on a formal, often printed,
            certificate.)'
          example: https://acme.workforce.mangoapps.com/recognition/awards/23/card
        share_url:
          type: string
          description: Absolute URL of the award **permalink** — the in-app feed presentation.
            This is what a Share action sends and what the certificate's scan-to-view
            QR should encode. Deliberately a different page from `certificate_url`,
            which would dead-end a phone that scanned it in a print dialog.
          example: https://acme.workforce.mangoapps.com/recognition/awards/23
    RecognitionCertificateMeta:
      type: object
      description: |-
        The printed certificate's fixed copy, sent so the native overlay says what the printed page says instead of hardcoding English strings in the client. Only `GET /recognitions/awards/{id}/certificate` carries it.

        **Copy only — no URLs.** The two the screen needs (`certificate_url` for Print/Save, `share_url` for the QR and Share) live on the certificate itself, where they belong to the award.
      required:
      - eyebrow
      - presented_label
      - awarded_by_prefix
      - scan_label
      properties:
        eyebrow:
          type: string
          description: The gold uppercase line above the award title.
          example: Certificate of Recognition
        presented_label:
          type: string
          description: The line between the award title and the recipient's name.
          example: is proudly presented to
        awarded_by_prefix:
          type: string
          description: Prefix for the giver line, rendered as "awarded by **{name}**".
            Omit the whole line when `awarded_by` is null.
          example: awarded by
        scan_label:
          type: string
          description: The caption under the scan-to-view QR square.
          example: Scan to view
    RecognitionAwardCycleResultsMeta:
      type: object
      description: The Award Results page's own copy and states, sent so the native
        screen says what the web screen says instead of inventing its own wording.
        Only `GET /recognitions/award_cycles/{id}/results` carries it — the list's
        `recent_winners` cards do not.
      required:
      - honor_roll_title
      - honor_roll_note
      - capabilities
      properties:
        announced_label:
          type: string
          description: The web page's subtitle under the cycle name, pre-formatted
            as it prints it. **Absent** when a decided cycle carries no `decided_at`,
            in which case the web page omits the line entirely — omit it too rather
            than printing "announced ".
          example: Winners announced July 13, 2026
        honor_roll_title:
          type: string
          description: The heading over the honor-roll card.
          example: Honor roll — everyone who was nominated
        honor_roll_note:
          type: string
          description: The thank-you line inside the honor-roll card.
          example: Being nominated by a colleague is itself recognition. Thank you,
            all of you.
        empty_state:
          type: object
          description: 'Present **only** when the cycle produced no winners — a real
            outcome for a Spotlight cycle nobody cleared the threshold in, and the
            state this copy describes. The honor roll can still be non-empty. Carries
            no `message`: the web state is a single line.'
          required:
          - key
          - title
          properties:
            key:
              type: string
              example: winners
            title:
              type: string
              example: No winners met the bar this cycle.
        capabilities:
          type: object
          description: The tenant toggles, so one payload shape describes both nomination
            models. `award_cycles_enabled` is always true in a 200 — the endpoint
            403s otherwise.
          properties:
            award_cycles_enabled:
              type: boolean
              example: true
            award_requests_enabled:
              type: boolean
              example: true
    RecognitionProgramCard:
      type: object
      description: 'One recognition program as the in-app card renders it: what it
        is, the viewer''s own standing in it, its remaining points, its categories,
        and whether the viewer may nominate right now.'
      properties:
        id:
          type: integer
          example: 3
        title:
          type: string
          description: The program's name — the card heading.
          example: Above & Beyond
        name:
          type: string
          description: The same value as `title`, under the record's own attribute
            name, so a client matching this row against the dashboard's `active_programs`
            (which names it `name`) doesn't have to know the two keys differ.
          example: Above & Beyond
        slug:
          type: string
          example: above-beyond
        description:
          type: string
          nullable: true
          example: Recognition for going above and beyond job duties
        status:
          type: string
          enum:
          - active
          - inactive
          - draft
          description: Always `active` in this payload — the list serves active programs
            only. Present because the card paints a status pill.
          example: active
        status_label:
          type: string
          description: Display form of `status`, humanized server-side so both surfaces
            agree.
          example: Active
        program_type:
          type: string
          enum:
          - peer_to_peer
          - manager_to_employee
          - achievement_based
          - milestone
          example: achievement_based
        program_type_label:
          type: string
          description: Display form of `program_type` — the card's type chip.
          example: Achievement Awards
        program_type_icon:
          type: string
          description: Font Awesome icon name the web chip uses (users / user-tie
            / trophy / flag-checkered / award), so the native chip carries the same
            glyph instead of maintaining its own mapping.
          example: trophy
        is_automatic:
          type: boolean
          description: True for a system-awarded milestone program. Swap the CTA for
            "Awarded automatically — no nomination needed" and hide the nominations
            stat, exactly as the web card does.
          example: false
        my_awards:
          type: integer
          description: Awards THIS viewer has received in this program. Active awards
            only — a revoked one is not something they hold, which is also how the
            dashboard tile and My Recognition count.
          example: 2
        my_nominations:
          type: integer
          description: Nominations THIS viewer has submitted to this program, in any
            status (pending, approved or rejected). Always 0 for an automatic program.
          example: 1
        starts_on:
          type: string
          format: date-time
          nullable: true
          description: When the nomination window opens. Null means open-ended, the
            common case. Only in-window programs are listed, so a non-null value here
            is always in the past.
          example: '2026-07-01T00:00:00.000Z'
        ends_on:
          type: string
          format: date-time
          nullable: true
          description: When the nomination window closes — for a client that wants
            to say "closes in N days". Null means open-ended.
          example: '2026-09-30T00:00:00.000Z'
        can_nominate:
          type: boolean
          description: Whether this viewer may file a nomination RIGHT NOW — the same
            answer the submit path gives. Hide the Nominate affordance when false.
          example: true
        nomination_block_reason:
          type: string
          nullable: true
          description: Why the CTA isn't offered, phrased for the person reading it.
            Null when `can_nominate` is true. Show it instead of a bare disabled button
            — it names what they can check or who to ask.
          example: Quarterly Star Award is a Manager Recognition program — only managers
            can nominate in it. Recognize a teammate through a peer-to-peer program
            instead.
        reward_points:
          type: object
          description: 'This month''s recognition budget for the program, in the POINTS
            employees see everywhere else in this app. Always present: an unbudgeted
            program (the common case) reports zeros and null dollars rather than omitting
            the block, so a client reads one shape.'
          properties:
            has_budget:
              type: boolean
              description: Whether the program runs a monthly budget at all. Render
                no bar when false.
              example: true
            points_remaining:
              type: integer
              description: Reward points remaining this month. Can be NEGATIVE when
                a program has overspent its allowance — the figure stays honest; only
                `percent_remaining` clamps.
              example: 1000
            total_points:
              type: integer
              description: The program's total monthly reward-point allowance.
              example: 5000
            percent_remaining:
              type: number
              format: float
              description: 0–100, already clamped. Draw the bar from this rather than
                dividing the two figures yourself — an overspent program would otherwise
                produce a negative width.
              example: 20.0
            monthly_budget_dollars:
              type: number
              format: float
              nullable: true
              description: "`total_points` in the stored currency unit, for a consumer
                that reports dollars. Null when the program has no budget."
              example: 50.0
            remaining_budget_dollars:
              type: number
              format: float
              nullable: true
              description: "`points_remaining` in the stored currency unit."
              example: 10.0
        categories:
          type: array
          description: The program's ACTIVE categories, in the tenant's sort order
            — the card's chips, and the choices the nominate form will offer.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 9
              name:
                type: string
                example: Project Completion
              slug:
                type: string
                example: project-completion
              icon:
                type: string
                description: Font Awesome name; falls back to `star` when unconfigured.
                example: check-circle
              color:
                type: string
                description: Hex colour; falls back to `#95a5a6` when unconfigured.
                example: "#27ae60"
        category_chip_limit:
          type: integer
          description: Where the web card collapses the rest into "+N more". Presentation
            guidance, not a rule — a narrower native card may break earlier — and
            `categories` always carries the full list, so expanding never needs a
            second request.
          example: 5
    RecognitionAwardCycleReview:
      allOf:
      - "$ref": "#/components/schemas/RecognitionAwardCycleReviewCard"
      - type: object
        description: 'The committee review screen for ONE closed committee cycle:
          the `needs_my_review` card it was opened from, plus the anonymized pool,
          the committee roster and this caller''s own ballot. Served whole — no pagination,
          because a submit replaces the entire ballot.'
        required:
        - committee
        - committee_count
        - nominees
        - pool_count
        - my_picks
        - meta
        properties:
          committee:
            type: array
            description: 'Everyone on this cycle''s review committee — the web page''s
              "Review committee: A and B" line, as people rather than a pre-joined
              sentence. Shown so a member sees they aren''t deciding alone. Includes
              the caller.'
            items:
              "$ref": "#/components/schemas/RecognitionPerson"
          committee_count:
            type: integer
            example: 2
          nominees:
            type: array
            description: The pool, highest tally first. Complete — its length IS the
              total.
            items:
              "$ref": "#/components/schemas/RecognitionCycleNominee"
          pool_count:
            type: integer
            description: 'Rows in `nominees`. May be lower than the card''s `nominee_count`
              when somebody nominated has since left the business: they drop out of
              the pool rather than rendering a blank row, though a pick already saved
              for them stays valid.'
            example: 3
          my_picks:
            type: object
            description: This caller's saved ballot for this cycle. Never anyone else's.
            required:
            - nominee_ids
            - count
            - submitted
            properties:
              nominee_ids:
                type: array
                items:
                  type: integer
                example:
                - 893
              count:
                type: integer
                example: 1
              submitted:
                type: boolean
                description: Whether this member has recorded a decision at all —
                  so a client can say "you haven't started" rather than showing an
                  empty ballot that looks the same as a deliberately-cleared one.
                example: true
          meta:
            "$ref": "#/components/schemas/RecognitionAwardCycleReviewMeta"
    RecognitionCycleNominee:
      type: object
      description: 'ONE nominee in the pool: who they are, how many colleagues put
        them forward, and a preview of what those colleagues wrote — never who wrote
        it.'
      required:
      - user
      - nomination_count
      - justifications
      - additional_justification_count
      - picked_by_me
      - is_me
      - can_pick
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        nomination_count:
          type: integer
          description: DISTINCT colleagues who nominated this person — the tally the
            pool is ordered by.
          example: 4
        justifications:
          type: array
          description: 'Anonymized reasons, oldest first, capped at `meta.justification_preview_limit`.
            Text and an opaque id ONLY: there is no nominator here and nothing that
            can be joined back to one.'
          items:
            type: object
            required:
            - id
            - text
            properties:
              id:
                type: integer
                description: Opaque row id, for list keying. It identifies the nomination,
                  never its author.
                example: 43
              text:
                type: string
                example: Christina keeps the team steady when the week goes sideways.
        additional_justification_count:
          type: integer
          description: How many justifications the cap hid — the web row's "…and N
            more nominations" line. 0 when everything fits.
          example: 0
        picked_by_me:
          type: boolean
          description: Whether THIS caller's saved ballot includes this nominee.
          example: true
        is_me:
          type: boolean
          description: The caller's own row. The web renders it disabled with a "You
            — recused" caption.
          example: false
        can_pick:
          type: boolean
          description: False only for the caller's own row — you never vote for yourself,
            and the submit path strips the id even if it is sent. Do not offer a control
            when this is false.
          example: true
    RecognitionAwardCycleReviewMeta:
      type: object
      description: The review page's own copy and the one rule a client must not get
        wrong about the write, sent so the native screen says what the web screen
        says instead of inventing its own wording.
      required:
      - title
      - instructions
      - anonymized
      - justification_preview_limit
      - submit_url
      - submit_label
      - submit_confirm
      - submit_replaces_previous
      - capabilities
      properties:
        title:
          type: string
          example: Committee review — Leadership Award — Q2
        instructions:
          type: string
          description: The web page's subtitle, verbatim.
          example: Nominations are shown anonymously (no nominator names). Pick the
            people you'd recognize — your choices are combined with the rest of the
            committee.
        anonymized:
          type: boolean
          description: 'Always true, and stated rather than implied: a client must
            not render nominator names on this screen, and there are none in the payload
            to render.'
          example: true
        justification_preview_limit:
          type: integer
          description: The cap `justifications` is sliced to. Pair it with `additional_justification_count`
            instead of implying the list is complete.
          example: 4
        submit_url:
          type: string
          description: Absolute URL of this cycle's ballot endpoint.
          example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/10/picks
        submit_label:
          type: string
          example: Save my recommendations
        submit_confirm:
          type: string
          description: The web page's confirmation prompt — it names the replace.
          example: Submit your recommendations for this cycle? This replaces any picks
            you saved earlier.
        submit_replaces_previous:
          type: boolean
          description: 'Always true. The one thing a client must get right about the
            write: it is a replace, not an append. Send the whole selection.'
          example: true
        empty_state:
          type: object
          description: Present **only** when the pool is empty — a committee cycle
            nobody nominated into. The cycle is still legitimately open for review;
            there is simply nothing to decide.
          required:
          - key
          - title
          properties:
            key:
              type: string
              example: nominees
            title:
              type: string
              example: No nominations to review.
        capabilities:
          type: object
          description: The tenant toggles, so a client can hide the same surfaces
            the web nav hides.
          properties:
            award_cycles_enabled:
              type: boolean
              example: true
            award_requests_enabled:
              type: boolean
              example: true
    RecognitionCycleNominationForm:
      type: object
      description: 'The nominate composer: the cycle being nominated into, the form
        to fill, and the page''s own copy.'
      required:
      - cycle
      - form
      - meta
      properties:
        cycle:
          allOf:
          - "$ref": "#/components/schemas/RecognitionAwardCycleOpenCard"
          description: The SAME card `GET /recognitions/award_cycles` serves under
            `open_now`, so a client that arrived from that list renders this screen
            from one model. Read `can_nominate` and `nomination_block_reason` from
            here — a closed or scheduled cycle still renders this screen, it just
            cannot be submitted.
        form:
          type: object
          description: Where to post, what the fields are called, and the bounds the
            model will enforce anyway — sent so a native client can validate before
            a round trip and fail in the same places the server does.
          required:
          - submit_url
          - submit_label
          - nominee_search_url
          - fields
          properties:
            submit_url:
              type: string
              description: Absolute URL to POST the nomination to.
              example: https://acme.workforce.mangoapps.com/api/v1/recognitions/award_cycles/12/nominations
            submit_label:
              type: string
              example: Submit nomination
            nominee_search_url:
              type: string
              description: 'The typeahead that fills `nominee_id` — `GET /recognitions/employee_suggestions`.
                Named rather than duplicated: this endpoint serves no roster of its
                own.'
              example: https://acme.workforce.mangoapps.com/api/v1/recognitions/employee_suggestions
            fields:
              type: object
              properties:
                nominee_id:
                  type: object
                  properties:
                    label:
                      type: string
                      example: Who are you nominating?
                    placeholder:
                      type: string
                      example: Search for the person you want to nominate...
                    help_text:
                      type: string
                      example: You can't nominate yourself, and one nomination per
                        person per award.
                    required:
                      type: boolean
                      example: true
                description:
                  type: object
                  properties:
                    label:
                      type: string
                      example: Why are you nominating them?
                    hint:
                      type: string
                      example: "(this is the signal — be specific)"
                    placeholder:
                      type: string
                      example: What did they do, and what impact did it have? Tie
                        it to the criteria above.
                    required:
                      type: boolean
                      example: true
                    min_length:
                      type: integer
                      example: 10
                    max_length:
                      type: integer
                      example: 1000
                title:
                  type: object
                  properties:
                    required:
                      type: boolean
                      example: false
                    max_length:
                      type: integer
                      example: 100
                    default:
                      type: string
                      description: What the server stores when `title` is omitted
                        — the cycle's name, truncated to `max_length`. Show it rather
                        than leaving the field blank.
                      example: Employee of the Quarter — Q3
        meta:
          type: object
          description: The page's own copy, so the native screen says what the web
            screen says.
          required:
          - title
          - allows_multiple_nominations
          - capabilities
          properties:
            title:
              type: string
              example: Nominate for Employee of the Quarter — Q3
            subtitle:
              type: string
              nullable: true
              description: The web page's subtitle, same wording and same date format.
              example: Nominations close Sep 30, 2026.
            criteria_label:
              type: string
              description: Present only when the cycle states criteria — the callout's
                label above the form.
              example: 'What we''re looking for:'
            criteria:
              type: string
              description: What a good nomination looks like. Absent (not null) when
                the cycle states none, so a client can test the key itself.
              example: Consistent, quiet leadership.
            allows_multiple_nominations:
              type: boolean
              description: Always true — a cycle POOLS nominations, so this is not
                a one-shot screen. Do NOT hide the CTA after one submit; the same
                nominator may come back for a different colleague. (A second nomination
                of the SAME person is what gets refused.)
              example: true
            capabilities:
              type: object
              properties:
                can_nominate:
                  type: boolean
                  example: true
                award_cycles_enabled:
                  type: boolean
                  example: true
                award_requests_enabled:
                  type: boolean
                  example: true
    RecognitionCycleNominationRequest:
      type: object
      description: 'The nomination. The `nomination` wrapper is optional — the four
        fields may be sent flat. `recognition_program_id` is NOT accepted: a cycle
        nomination belongs to the program running the cycle.'
      properties:
        nomination:
          type: object
          required:
          - nominee_id
          - description
          properties:
            nominee_id:
              type: integer
              description: The colleague being put forward. Must be an active member
                of this tenant, and cannot be the caller.
              example: 893
            description:
              type: string
              minLength: 10
              maxLength: 1000
              description: Why. The signal the cycle is decided on.
              example: Rebuilt the onboarding checklist and cut new-hire ramp by a
                week.
            title:
              type: string
              maxLength: 100
              description: Optional. Defaults to the cycle's name, truncated.
              example: The onboarding rebuild
            is_public:
              type: boolean
              description: Optional. Defaults to the column default (public).
              example: true
    RecognitionCycleNominationResult:
      type: object
      description: The nomination as filed, plus the cycle card with it counted.
      required:
      - id
      - message
      - nominee
      - nominator
      - title
      - description
      - status
      - pooled
      - cycle
      properties:
        id:
          type: integer
          example: 4471
        message:
          type: string
          description: The web flash, verbatim — it names the person and the cycle.
          example: Your nomination for Ada Lovelace has been added to Employee of
            the Quarter — Q3.
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          "$ref": "#/components/schemas/RecognitionPerson"
        title:
          type: string
          example: Employee of the Quarter — Q3
        description:
          type: string
          example: Rebuilt the onboarding checklist and cut new-hire ramp by a week.
        status:
          type: string
          description: Always `pending` on this path, and that is the point — see
            `pooled`.
          enum:
          - pending
          example: pending
        pooled:
          type: boolean
          description: 'Always true. A cycle nomination is POOLED, not reviewed: no
            reviewer is assigned, no Award is minted, and there is no per-nomination
            approve/reject. The cycle decides when it closes. Do NOT render the Model
            A "awaiting approval" affordance against this row.'
          example: true
        submitted_at:
          type: string
          format: date-time
        is_public:
          type: boolean
          example: true
        is_anonymous:
          type: boolean
          description: False unless the tenant allows anonymity. Cycle nominations
            are attributed by default.
          example: false
        cycle:
          allOf:
          - "$ref": "#/components/schemas/RecognitionAwardCycleOpenCard"
          description: The cycle AFTER the write, so `nomination_count`, `nominee_count`,
            `nominated_by_me` and `my_nomination_count` already reflect this submission.
            Re-render the row the client came from off THIS rather than re-fetching
            the list.
    RecognitionCycleNominationRefusal:
      type: object
      description: Every refusal from either verb. `error.details` always names the
        cycle and its RECONCILED state, because two of the refusals are about that
        state and the client's next move is to re-render the card.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - feature_disabled
              - giving_not_allowed
              - cycle_not_open
              - invalid_nominee
              - validation_failed
              - content_rejected
              - nomination_failed
              example: cycle_not_open
            message:
              type: string
              description: The web page's own wording for the same refusal.
              example: That award isn't open for nominations right now.
            details:
              type: object
              properties:
                cycle_id:
                  type: integer
                  example: 12
                status:
                  type: string
                  description: The cycle's EFFECTIVE status, not its stored column
                    — a cycle stored `scheduled` whose window has opened reads `open`
                    here, and an admin-forced close reads `closed` while `closes_at`
                    is still in the future.
                  enum:
                  - scheduled
                  - open
                  - closed
                  - decided
                  - archived
                  example: scheduled
                errors:
                  type: array
                  description: "`validation_failed` / `content_rejected` only — the
                    messages, as one list."
                  items:
                    type: string
                  example:
                  - You've already nominated this person for this award.
                field_errors:
                  type: object
                  description: '`validation_failed` / `content_rejected` only — per-field
                    messages, so a native form can mark the offending field instead
                    of parsing a sentence. A content refusal names the field whose
                    text was rejected, e.g. `{"description": ["contains inappropriate
                    language or terms that aren''t allowed"]}`.'
                  additionalProperties:
                    type: array
                    items:
                      type: string
                  example:
                    nominee:
                    - cannot be the same as the nominator
    RecognitionCyclePicksRequest:
      type: object
      description: The member's COMPLETE ballot for this cycle. Supply `nominee_ids`
        OR `nominee_id` — sending neither is refused with 422 `no_selection` rather
        than read as "clear everything".
      properties:
        nominee_ids:
          description: Every nominee this member recommends. An array of ids, or the
            web's comma-separated string form. `[]` CLEARS the ballot. This replaces
            the saved set — it is not a delta.
          oneOf:
          - type: array
            items:
              type: integer
          - type: string
          example:
          - 893
          - 265
        nominee_id:
          type: integer
          description: Single-pick shorthand — identical to sending a one-element
            `nominee_ids`, including the replace. Ignored when `nominee_ids` is present.
          example: 893
    RecognitionCyclePicksResult:
      type: object
      description: The member's ballot AFTER the write. Re-render from this, not from
        what was sent.
      required:
      - cycle_id
      - cycle_name
      - message
      - picked_ids
      - picked_count
      - picked
      - ignored_ids
      - previous_picked_ids
      - changed
      properties:
        cycle_id:
          type: integer
          example: 10
        cycle_name:
          type: string
          example: Leadership Award — Q2
        message:
          type: string
          description: The web flash, verbatim — including the honest empty case,
            which says "cleared" rather than "saved".
          example: Your recommendations for Leadership Award — Q2 were saved.
        picked_ids:
          type: array
          items:
            type: integer
          description: The saved ballot. Authoritative.
          example:
          - 893
          - 265
        picked_count:
          type: integer
          example: 2
        picked:
          type: array
          description: The people behind `picked_ids`, name-ordered, for an immediate
            toast or summary.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        ignored_ids:
          type: array
          items:
            type: integer
          description: Ids that were sent but could not be picked — not in this cycle's
            pool, or the caller themselves. Empty on a well-formed call; a non-empty
            one means the client's list is stale.
          example: []
        previous_picked_ids:
          type: array
          items:
            type: integer
          description: The ballot this call replaced.
          example:
          - 893
        changed:
          type: boolean
          description: False when the submitted ballot matched the saved one — skip
            the "saved" toast on a no-op re-submit.
          example: true
        review_url:
          type: string
          description: Absolute URL of the web review page for this cycle.
          example: https://acme.workforce.mangoapps.com/recognition/award-cycles/10/review
    RecognitionCycleReviewRefusal:
      type: object
      description: A committee-review refusal. The message is the WEB page's own flash
        wording — both surfaces read it from one place, so they cannot drift.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
              - access_denied
              - feature_disabled
              - not_committee_cycle
              - not_committee_member
              - cycle_not_closed
              - recused
              example: recused
            message:
              type: string
              example: You're nominated in this cycle, so you can't take part in the
                decision.
            details:
              type: object
              description: Present on the four gate refusals (not on `access_denied`
                / `feature_disabled`, which are about the tenant, not the cycle).
              properties:
                cycle_id:
                  type: integer
                  example: 10
                status:
                  type: string
                  enum:
                  - scheduled
                  - open
                  - closed
                  - decided
                  - archived
                  example: closed
    RecognitionReactionState:
      type: object
      description: One recognition's reaction state AFTER a write — the same keys
        the `engagement` block carries on `GET /recognitions/feed` and `GET /recognitions/posts/{id}`,
        so a client patches the card it already holds instead of re-fetching the screen.
      required:
      - item_type
      - item_id
      - emoji
      - reacted
      - reactions_count
      - reaction_summary
      - my_reactions
      properties:
        item_type:
          type: string
          enum:
          - recognition_post
          - award
          - recognition_comment
          description: The CANONICAL type, even when the request used an alias.
          example: recognition_post
        item_id:
          type: integer
          example: 4471
        emoji:
          type: string
          description: The glyph this call toggled.
          example: "\U0001F389"
        reacted:
          type: boolean
          description: True when this call ADDED the reaction (HTTP 201), false when
            it removed one (HTTP 200).
          example: true
        reactions_enabled:
          type: boolean
          description: The tenant's reaction switch, so a client can retire the bar
            in step with it.
          example: true
        reactions_count:
          type: integer
          description: Every reaction on this recognition, from every person.
          example: 7
        reaction_summary:
          type: array
          description: Grouped counts, most-reacted first (the glyph breaks a tie).
          items:
            type: object
            properties:
              emoji:
                type: string
                example: "\U0001F44D"
              count:
                type: integer
                example: 4
        my_reaction:
          type: string
          nullable: true
          description: The caller's first emoji on this recognition — the single-glyph
            convenience the feed and detail cards render. Null when they hold none.
          example: "\U0001F389"
        my_reactions:
          type: array
          description: EVERY emoji this caller holds on this recognition. A viewer
            may hold several at once, each toggled independently.
          items:
            type: string
          example:
          - "\U0001F389"
          - "\U0001F525"
        unread_notification_count:
          type: integer
          example: 3
    RecognitionReactor:
      type: object
      description: One reaction with the person who left it, as `GET /recognitions/reactions`
        pages them.
      properties:
        id:
          type: integer
          example: 90210
        emoji:
          type: string
          example: "\U0001F389"
        label:
          type: string
          description: The friendly name for the glyph, for tooltips and screen readers.
          example: Celebrate
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        reacted_at:
          type: string
          format: date-time
    RecognitionReactionSummary:
      type: object
      description: |-
        The per-emoji rollup a reactor sheet's tabs are built from, shipped alongside the paged list on `GET /recognitions/reactions` and `GET /recognitions/comments/{id}/reactions`.
        Computed over the **whole set**, not the page, and **not** narrowed by `?emoji=` — the tabs must keep showing the glyphs the reader can switch *to*, and their counts must not shrink as the reader pages. Two aggregate queries, so a thousand reactions cost the same as three.
        Same keys as the `engagement` block on `GET /recognitions/feed` and `GET /recognitions/posts/{id}`, so a client has one shape to parse.
      required:
      - reactions_count
      - reaction_summary
      - my_reactions
      - reactions_enabled
      properties:
        reactions_count:
          type: integer
          description: Every reaction on this item, from every person.
          example: 7
        reaction_summary:
          type: array
          description: Grouped counts, most-reacted first (the glyph breaks a tie).
          items:
            type: object
            properties:
              emoji:
                type: string
                example: "\U0001F44D"
              count:
                type: integer
                example: 4
        my_reaction:
          type: string
          nullable: true
          description: The caller's first emoji on this item — the single-glyph convenience
            the feed and detail cards render. Null when they hold none.
          example: "\U0001F389"
        my_reactions:
          type: array
          description: EVERY emoji this caller holds on this item.
          items:
            type: string
          example:
          - "\U0001F389"
          - "\U0001F525"
        reactions_enabled:
          type: boolean
          description: The tenant's reaction switch. The list itself is deliberately
            NOT gated on it (turning reactions off retires the affordance, it does
            not retract what people left), so this is how a client reading only this
            endpoint knows whether to render the bar.
          example: true
    RecognitionReactionsPage:
      type: object
      description: The page envelope on `GET /recognitions/reactions`.
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 25
        total_count:
          type: integer
          example: 7
        total_pages:
          type: integer
          example: 1
        has_next_page:
          type: boolean
          example: false
        has_prev_page:
          type: boolean
          example: false
    RecognitionAcknowledgement:
      type: object
      description: 'The answer from `POST /recognitions/{posts,awards}/{id}/acknowledge`.
        Always a `200`: "you are not the person recognized" is the ordinary case on
        a shared card, not an error.'
      required:
      - type
      - id
      - acknowledged
      - newly_acknowledged
      properties:
        type:
          type: string
          enum:
          - recognition_post
          - award
          description: The same vocabulary `unacknowledged_recognitions` emits on
            `GET /api/v1/apps`, so the `type` a client is given is the one it reads
            back.
          example: recognition_post
        id:
          type: integer
          description: The row that was stamped. For a GROUP give this is the caller's
            OWN sibling row, which may differ from the id in the path.
          example: 4471
        acknowledged:
          type: boolean
          description: Whether this recognition is settled — `true` for a repeat call,
            and `false` when the caller is not the person recognized (nothing was
            written).
          example: true
        newly_acknowledged:
          type: boolean
          description: 'Whether THIS call settled it. The flag to key the animation
            off: a re-opened card never re-celebrates.'
          example: true
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
          description: When it was acknowledged; `null` when nothing was written.
          example: '2026-08-18T09:14:22Z'
        reason:
          type: string
          enum:
          - not_recipient
          - not_active
          - invalid_item
          - error
          description: Present only when `acknowledged` is `false`. DIAGNOSTIC, never
            a message to show a user — `not_recipient` is the ordinary answer whenever
            somebody other than the person recognized opens a card.
          example: not_recipient
    RecognitionFeedItem:
      type: object
      description: One recognition card. The identity half (type, id, title, message,
        points, people, program, value, timestamp) is shared with the dashboard's
        activity rows; the rest is what the browsable feed card renders on top.
      properties:
        type:
          type: string
          enum:
          - award
          - recognition_post
          description: "`award` is a formal program award; `recognition_post` is peer
            recognition. Clients use this to pick the card treatment."
        id:
          type: integer
          example: 8814
        title:
          type: string
          nullable: true
          description: Award title. Null for peer posts, which lead with the message.
          example: Excellence Award
        message:
          type: string
          nullable: true
          description: The recognition text — an award's description, or a post's
            content.
          example: Thank you for the incredible help on the release deadline.
        points:
          type: integer
          description: Reward points the RECIPIENT earned. An award stores dollars
            and converts; a peer post stores points directly. Always present (0 when
            none).
          example: 250
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        group_recipients:
          type: array
          nullable: true
          description: Present only on a collapsed GROUP give — every recipient of
            the one recognition, so a client can render "Alice, Bob and 3 others".
            Null for an ordinary single-recipient card.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Null when `anonymous` is true. For an automated lifecycle award
            this is the "System (Automated)" pseudo-person with a null id — the system
            principal is never exposed.
        anonymous:
          type: boolean
          description: The giver chose to stay unattributed; `giver` is null.
          example: false
        program:
          type: string
          nullable: true
          example: Spot Award
        category:
          type: string
          nullable: true
          example: Project Completion
        company_value:
          type: string
          nullable: true
          description: The company value this recognition celebrates.
          example: Teamwork
        company_values:
          type: array
          description: Every company value it celebrates, primary first — up to three.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Customer First
        occurred_at:
          type: string
          format: date-time
          description: When the recognition happened (an award's awarded_at, a post's
            published_at).
        visibility:
          type: string
          nullable: true
          enum:
          - public
          - department
          - team
          - private
          description: Audience of a peer post. Null for awards, which are public
            by definition in this feed.
        tags:
          type: array
          description: Free-form recognition tags. Always an array (empty when none).
          items:
            type: string
          example:
          - teamwork
          - excellence
        photo_url:
          type: string
          nullable: true
          description: Absolute URL of the photo the giver attached, as a resized
            WebP rendition (never the original upload). Null when there is none.
        award_art_url:
          type: string
          nullable: true
          description: Absolute URL of the award-card artwork, when one was chosen.
        engagement:
          type: object
          description: Reaction / comment / share counts, plus the caller's own reaction.
          properties:
            reactions_count:
              type: integer
              example: 4
            reactions_enabled:
              type: boolean
              description: Tenant setting — hide the reaction affordance when false.
            my_reaction:
              type: string
              nullable: true
              description: The emoji the caller reacted with, or null.
              example: "\U0001F389"
            reaction_summary:
              type: array
              description: Per-emoji totals, most-used first.
              items:
                type: object
                properties:
                  emoji:
                    type: string
                    example: "\U0001F389"
                  count:
                    type: integer
                    example: 3
            comments_count:
              type: integer
              example: 2
            comments_enabled:
              type: boolean
              description: Tenant setting — hide the comment affordance when false.
            shares_count:
              type: integer
              example: 0
        boost:
          type: object
          nullable: true
          description: Pile-on points — teammates adding their own allowance to someone
            else's recognition. **Null for awards**, which cannot be boosted, so clients
            should treat null as "no boost affordance" rather than zero.
          properties:
            total_points:
              type: integer
              description: Points teammates have piled on so far.
              example: 40
            boosted_by_me:
              type: boolean
              description: A boost is one per person; true means the caller already
                did.
            can_boost:
              type: boolean
              description: False on the caller's own give or receipt, when they have
                already boosted, when peer points are off, or when their remaining
                allowance covers none of the offered amounts.
            amounts:
              type: array
              description: Amounts the server will actually accept, already filtered
                to the caller's remaining allowance. Empty when they cannot boost.
              items:
                type: integer
              example:
              - 5
              - 10
              - 25
        permissions:
          type: object
          description: What this caller may do with this item, matching what the server
            would allow. This is where an admin's feed differs from an employee's
            — everything else is identical.
          properties:
            can_delete:
              type: boolean
              description: True for the post's author, a business admin, or a Recognitions
                app admin.
            can_edit:
              type: boolean
              description: 'What PATCH /recognitions/posts|awards/{id} would accept:
                the author or a moderator on a post, a moderator on an award, and
                in both cases only while the recognition is still active. NOT the
                same gate as can_delete — a held post is deletable but not editable,
                and an award is editable by a moderator while can_delete is withheld
                on a feed row.'
            can_share:
              type: boolean
              description: Public, non-anonymous, active items only.
            can_comment:
              type: boolean
            can_react:
              type: boolean
            can_boost:
              type: boolean
              description: Mirrors `boost.can_boost`; always false for awards.
    RecognitionFeedMeta:
      type: object
      description: Paging state, the active filter, and viewer-level capabilities.
      properties:
        filter:
          type: string
          enum:
          - all
          - awards
          - posts
          - my_team
          - my_department
          description: The filter actually applied (an unknown value falls back to
            `all`).
        available_filters:
          type: array
          description: Every filter this endpoint accepts, in the order the web page
            shows them.
          items:
            type: string
          example:
          - all
          - awards
          - posts
          - my_team
          - my_department
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next_page:
          type: boolean
          description: "**The authoritative paging signal — always exact.** Page until
            this is false; do not drive paging from the totals below."
        has_prev_page:
          type: boolean
        total_count:
          type: integer
          description: Number of items in the loaded window after filtering. The feed
            merges two tables and applies some filters in memory, so an exact total
            would mean loading everything — read `total_count_exact` before showing
            this.
          example: 37
        total_count_exact:
          type: boolean
          description: True when `total_count` is the real total; false when it is
            only a lower bound because the window was truncated. **Do not render "of
            N" unless this is true.**
        total_pages:
          type: integer
          nullable: true
          description: Null whenever `total_count_exact` is false — a null instead
            of a confidently wrong page count.
          example: 2
        capped_at:
          type: integer
          description: Maximum rows read per source. The feed will not page beyond
            this many items even if more exist in the 30-day window.
          example: 200
        capabilities:
          type: object
          description: Viewer-level state the client needs to render the feed's global
            actions, so it never offers something the server would refuse.
          properties:
            can_give_recognition:
              type: boolean
              description: Whether this caller may give peer recognition at all.
            boosting_available:
              type: boolean
              description: Peer points are enabled AND this caller may give.
            giving_remaining:
              type: integer
              description: Points left in the caller's monthly giving allowance.
              example: 120
            reactions_enabled:
              type: boolean
            comments_enabled:
              type: boolean
    RecognitionLeaderboardRow:
      type: object
      required:
      - rank
      - user_id
      - user
      - name
      - count
      - is_me
      properties:
        rank:
          type: integer
          description: '1-based POSITION in this board — what the medallion renders
            (gold #1, silver #2, bronze #3, neutral from #4). Not the competition-style
            rank in `my_standing`: tied rows occupy consecutive positions here.'
          example: 1
        user_id:
          type: integer
          example: 1033
        user:
          type: object
          nullable: true
          description: Null when the person has left the business since being recognized.
            The row is KEPT (with `name` degrading to "Unknown") rather than vanishing
            and shifting every rank below it.
          properties:
            id:
              type: integer
              example: 1033
            name:
              type: string
              example: Weston D'Amore
            title:
              type: string
              nullable: true
              description: Job title. The native row shows title, falling back to
                department.
              example: Area Manager
            department:
              type: string
              nullable: true
              example: Sales Department
            image:
              type: string
              nullable: true
              description: Absolute avatar URL.
        name:
          type: string
          description: The display name for the row. "Unknown" when `user` is null,
            so the row still renders.
          example: Weston D'Amore
        count:
          type: integer
          description: Recognition received (recipients) or given (givers) in the
            window, under the reported `counting_basis`.
          example: 6
        is_me:
          type: boolean
          description: Whether this row is the caller — the "You" badge.
          example: false
    RecognitionLeaderboardStanding:
      type: object
      required:
      - rank
      - count
      - in_top
      - ranked_total
      properties:
        rank:
          type: integer
          nullable: true
          description: 'Competition-style 1-based rank against the FULL board — ties
            SHARE a rank. Null when the caller has no activity in the window: unranked,
            not last. Render nothing rather than a zero row.'
          example: 7
        count:
          type: integer
          description: The caller's own count in the window. 0 when unranked.
          example: 1
        in_top:
          type: boolean
          description: 'Whether the caller already appears in the returned rows. When
            false and `rank` is present, pin a "You — #N" footer row; when true, don''t,
            or it duplicates a row already drawn.'
          example: false
        ranked_total:
          type: integer
          description: How many people appear on the full board — the denominator
            behind `rank`, so "#7" can be shown as "#7 of 19" without a second request.
          example: 19
    RecognitionTeamCountTile:
      type: object
      description: One of the two count tiles. `total` IS `awards + shout_outs` —
        render the total large and the split as the sub-line, not three separate numbers.
      properties:
        total:
          type: integer
          example: 34
        awards:
          type: integer
          description: Formal awards (dollar-denominated on the record).
          example: 12
        shout_outs:
          type: integer
          description: Peer recognition posts. Every post counts, including 0-point
            ones — a tenant not using redeemable rewards collects no points at all.
          example: 22
    RecognitionTeamGivingPool:
      type: object
      description: One pool the viewer can give from. Points are pre-converted, so
        a client never needs the tenant's points-per-dollar rate.
      properties:
        label:
          type: string
          example: Spot Awards
        sublabel:
          type: string
          example: Manager to employee budget
        kind:
          type: string
          enum:
          - allowance
          - budget
          description: "`allowance` is the monthly peer-giving pool; `budget` is an
            annual award-program allocation. The two never draw on each other."
          example: budget
        available_points:
          type: integer
          example: 1500
        allocated_points:
          type: integer
          example: 5000
        percent_remaining:
          type: integer
          description: "`available / allocated`, clamped to 0..100 — a manager who
            overspent a budget must not drive a negative-width progress bar."
          example: 30
        period:
          type: string
          description: The window the allocation covers — an allowance resets monthly,
            a budget annually.
          example: this year
    RecognitionTeamPendingNomination:
      type: object
      properties:
        id:
          type: integer
          example: 88
        title:
          type: string
          example: Renewal save of the quarter
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          "$ref": "#/components/schemas/RecognitionPerson"
        program:
          type: string
          nullable: true
          example: Spot Awards
        status:
          type: string
          example: pending
        submitted_at:
          type: string
          format: date-time
    RecognitionApprovalsPageMeta:
      type: object
      description: Kaminari paging state for one of the two approvals lists. Counts
        are exact (each list is one business-scoped relation), so `total_pages` is
        always a real number and `has_next_page` is authoritative.
      properties:
        current_page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        total_count:
          type: integer
          example: 37
        total_pages:
          type: integer
          example: 2
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    RecognitionApprovalNomination:
      type: object
      description: One nomination awaiting the reviewer's decision.
      properties:
        id:
          type: integer
          example: 88
        title:
          type: string
          example: Renewal save of the quarter
        description:
          type: string
          nullable: true
          example: Turned a churn risk into a multi-year renewal.
        justification:
          type: string
          nullable: true
          description: The nominator's justification — why this person deserves the
            award. Null when none was given. Distinct from `description`.
          example: Saved a seven-figure renewal by rebuilding the rollout plan in
            a week.
        nominee:
          "$ref": "#/components/schemas/RecognitionPerson"
        nominator:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Withheld (null) when the nomination is anonymous.
        anonymous:
          type: boolean
          description: True when the nominator is withheld.
        program:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 5
            name:
              type: string
              example: Spot Awards
        category:
          type: object
          nullable: true
          description: The award category, with the chip's icon/color fallbacks. Null
            when none is set.
          properties:
            id:
              type: integer
              example: 12
            name:
              type: string
              example: Above and Beyond
            icon:
              type: string
              example: fa-star
            color:
              type: string
              example: "#f59e0b"
        requested_points:
          type: integer
          nullable: true
          description: The requested award value in reward points (dollars converted
            server-side). Null when no value was requested.
          example: 250
        status:
          type: string
          example: pending
          description: "`pending` or `under_review`."
        multi_level_approval:
          type: boolean
          description: True when the program needs more than one approval level.
        approval_progress:
          type: object
          description: Which approval step this nomination is on, of how many.
          properties:
            current:
              type: integer
              example: 1
            required:
              type: integer
              example: 1
        submitted_at:
          type: string
          format: date-time
          nullable: true
        supporting_documents:
          type: object
          description: The reviewer's evidence. `count` is the ledger size; `files`
            reads through to the live blobs; `missing_count` is ledger rows whose
            bytes are gone (surfaced so a shrinking list is visible, not silent).
          properties:
            count:
              type: integer
              example: 2
            missing_count:
              type: integer
              example: 0
            files:
              type: array
              items:
                type: object
                properties:
                  filename:
                    type: string
                    example: renewal-contract.pdf
                  content_type:
                    type: string
                    example: application/pdf
                  byte_size:
                    type: integer
                    example: 84213
                  icon:
                    type: string
                    example: fa-file-pdf
                  download_url:
                    type: string
                    description: Absolute, attachment-disposition download URL for
                      the blob.
    RecognitionApprovalPost:
      type: object
      description: 'One approval request for peer recognition held for manager approval.
        A group give''s posts waiting on the same approver are ONE request — listed
        once, counted once, and decided together: approving or rejecting `id` decides
        every post in `post_ids`.'
      properties:
        id:
          type: integer
          example: 401
        message:
          type: string
          example: Thanks for covering the late shift.
        author:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Withheld (null) when the post is anonymous.
        anonymous:
          type: boolean
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        recipients:
          type: array
          description: Everyone this request covers — one entry for a single recognition,
            each recipient of a group give who shares this approver otherwise.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        post_ids:
          type: array
          description: The posts one decision on this request approves or rejects.
          items:
            type: integer
          example:
          - 401
          - 402
          - 403
        points:
          type: integer
          description: The peer points attached to EACH post of the request (already
            in points; 0 when none). Approving a request that covers several posts
            settles this amount for every post in `post_ids`.
          example: 500
        has_award_art:
          type: boolean
          description: The post carries illustrated award art (an Asset Library card).
        has_attachments:
          type: boolean
          description: The post carries a photo or file attachments.
        company_value:
          type: string
          nullable: true
          example: Customer First
        company_values:
          type: array
          description: Every company value it celebrates, primary first — up to three.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Customer First
        reviewer:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: The reviewer the post routes to (useful when an admin views
            the whole tenant's held posts).
        submitted_at:
          type: string
          format: date-time
    RecognitionTeamGapRow:
      type: object
      description: A team member nobody has recognized lately.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        days_since_recognition:
          type: integer
          nullable: true
          description: Null when this person has NEVER been recognized. Render "Never
            recognized" for that case — never "0 days ago".
          example: 47
        last_recognized_at:
          type: string
          format: date-time
          nullable: true
          description: Null alongside a null `days_since_recognition`.
        never_recognized:
          type: boolean
          description: True exactly when `days_since_recognition` is null.
          example: false
    RecognitionTeamAnniversary:
      type: object
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          nullable: true
          example: Support Specialist
        department:
          type: string
          description: The person's department, or "Unassigned".
          example: Customer Success
        years:
          type: integer
          description: Years of service they are about to complete. Always >= 1.
          example: 5
        milestone_label:
          type: string
          description: Ready-to-render label — "Decade", "Silver (25)", "3 Years".
          example: 5 Years
        anniversary_date:
          type: string
          format: date
        days_until:
          type: integer
          description: 0 means today.
          example: 12
        recognized:
          type: boolean
          description: Whether their years-of-service award was already given this
            year. The web hides the Celebrate button when true — offering it again
            invites a duplicate award.
          example: false
    RecognitionAnniversaryRosterRow:
      type: object
      description: ONE roster row — the same information the web table prints, minus
        its link chrome. Richer than `RecognitionTeamAnniversary` (the Team screen's
        teaser), which carries no hire date, tier, status or action flag.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          description: 'The resolved title the row prints: the person''s job title,
            falling back to their org role and then "No title". (`user.title` is the
            job title alone, with no fallback, and may be null.)'
          example: Support Specialist
        department:
          type: string
          description: The person's department, or "Unassigned".
          example: Customer Success
        hire_date:
          type: string
          format: date
          description: The date the anniversary counts from — their original hire
            date when one is recorded, so a rehire keeps their full service length.
        anniversary_date:
          type: string
          format: date
          description: The upcoming anniversary itself, inside the applied window.
        years:
          type: integer
          description: Years of service they are about to complete. Always >= 1.
          example: 5
        milestone_label:
          type: string
          description: Ready-to-render label — "Decade", "Silver (25)", "3 Years".
          example: 5 Years
        milestone_tier:
          type: string
          enum:
          - early_career
          - established
          - veteran
          - senior
          - legendary
          description: The service-length band, so a client can tint the year chip
            the way the web badge and its legend do without re-deriving the boundaries
            (1-4 / 5-9 / 10-19 / 20-24 / 25+).
          example: established
        milestone_tier_label:
          type: string
          description: The band's display name, matching the web legend.
          example: Established
        days_until:
          type: integer
          description: 0 means today. **Signed** — negative when `from_date` is in
            the past, so render "3 days ago" rather than an impossible countdown.
          example: 12
        is_upcoming_soon:
          type: boolean
          description: Within a week and not yet past — the rows the web table tints
            green.
          example: false
        recognized:
          type: boolean
          description: Whether their years-of-service award was already given this
            year.
          example: false
        status:
          type: string
          enum:
          - pending
          - recognized
          - rewarded
          description: The web badge's three states. `rewarded` means the gift/fulfilment
            has actually gone out, which is a step beyond `recognized`.
          example: pending
        recognized_via:
          type: string
          enum:
          - award
          - acknowledgment
          nullable: true
          description: 'How a `recognized` row was recognized, as the web row words
            it: `award` when a person gave the years-of-service award themselves ("Award
            given"), `acknowledgment` when a leader acknowledged the automatic award
            ("Acknowledged"). Null for `pending` and `rewarded` rows.'
          example: award
        rewarded_at:
          type: string
          format: date-time
          nullable: true
          description: Null unless `status` is `rewarded`.
        reward_details:
          type: string
          nullable: true
          description: What was sent, when the tenant recorded it.
          example: Engraved watch
        can_recognize:
          type: boolean
          description: |-
            Whether to offer the Recognize / Celebrate action on THIS row — the tenant allows instant awards AND this milestone hasn't been awarded yet. Offering it on an already-recognized row invites a duplicate award, which is why the web hides it there too.

            The action is `POST /recognitions/quick_award`, and it must carry `anniversary_years` set to this row's `years` — that is what records the recognition and flips this row to `recognized: true`.
          example: true
    RecognitionTeamHighlight:
      type: object
      nullable: true
      description: Null when the team has no recognition at all. `count` agrees with
        that person's matching column in `team_members`.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        count:
          type: integer
          example: 12
    RecognitionTeamActivityRow:
      type: object
      description: One recognition, normalized across formal awards and peer shout-outs
        so the list renders through one row shape.
      properties:
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        group_recipients:
          type: array
          nullable: true
          description: Present only on a collapsed GROUP give — one recognition fanned
            out to several people arrives as ONE row naming all of them, because rendering
            each separately let a single ten-person shout-out fill the whole list.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        recipient_label:
          type: string
          description: '"Alice, Bob and Carol" for a collapsed group give, the single
            recipient''s name otherwise — so no client-side name joining is needed.'
          example: Ada Lovelace and Bo Diaz
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Null when `anonymous` is true — anonymity is enforced server-side,
            not merely hidden by the client.
        anonymous:
          type: boolean
          example: false
        program:
          type: string
          description: The award program's name, or "Peer Recognition" for a shout-out.
          example: Peer Recognition
        message:
          type: string
          nullable: true
          description: The award title or the shout-out body.
        points:
          type: integer
          description: The recognition's value in reward points. 0 for a pointless
            shout-out.
          example: 250
        occurred_at:
          type: string
          format: date-time
    RecognitionTeamMemberRow:
      type: object
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          nullable: true
          description: Their own job title, falling back to their organizational role.
          example: Support Specialist
        received_count:
          type: integer
          description: All-time awards + shout-outs received, under the tenant-wide
            counting rules.
          example: 8
        given_count:
          type: integer
          example: 3
        this_month_count:
          type: integer
          description: Received in the current CALENDAR month — always a subset of
            `received_count`.
          example: 2
    RecognitionProfileSubject:
      type: object
      description: The person whose recognition profile this is. Same shape as a `team_members`
        row from `/recognitions/team`, so the roster and the detail screen are one
        client model.
      properties:
        user:
          "$ref": "#/components/schemas/RecognitionPerson"
        job_title:
          type: string
          nullable: true
          description: Their own job title, falling back to their organizational role
            in THIS business. Null when neither is set — the web renders the generic
            "Team Member" for that case.
          example: Print Operator
    RecognitionProfileMonthTile:
      type: object
      description: One "This Month" tile, over the current CALENDAR month. The total
        is a roll-up of BOTH recognition tables, matching the "This Month" column
        on the roster that links here; the split is published so a client can render
        the sub-line without a second request.
      properties:
        total:
          type: integer
          description: awards + shout_outs.
          example: 20
        awards:
          type: integer
          example: 5
        shout_outs:
          type: integer
          example: 15
    RecognitionProfileSection:
      type: object
      description: One of the profile's three lists. Not paginated — the latest `limit`
        rows, newest first, plus the full `total_count`, which together are what the
        web prints as "Showing latest 20 of N". Use `/recognitions/feed` for deeper
        history.
      properties:
        total_count:
          type: integer
          description: Every row that matches, before the limit — so a client can
            say "20 of 47" rather than implying the list is complete.
          example: 47
        showing:
          type: integer
          description: Rows actually in `items`.
          example: 20
        limit:
          type: integer
          description: The server's per-list cap.
          example: 20
        has_more:
          type: boolean
          description: "`total_count` exceeds `limit`."
          example: true
        items:
          type: array
          items:
            "$ref": "#/components/schemas/RecognitionProfileRow"
    RecognitionProfileRow:
      type: object
      description: One recognition, normalized across formal awards and peer shout-outs
        so all three lists render through one row shape. This is the same identity
        half as `RecognitionFeedItem` (and the dashboard's activity rows) — the same
        server-side serializer — minus the browsable feed's own engagement, boost
        and permission blocks. Open the full detail with `GET /recognitions/awards/{id}`
        or `GET /recognitions/posts/{id}` using `type` to pick the path.
      properties:
        type:
          type: string
          enum:
          - award
          - recognition_post
          description: "`award` is a formal program award; `recognition_post` is peer
            recognition. Also selects the detail path for this row."
        id:
          type: integer
          example: 8814
        title:
          type: string
          nullable: true
          description: Award title. Null for peer posts, which lead with the message.
          example: Excellence Award
        message:
          type: string
          nullable: true
          description: The recognition text — an award's description, or a post's
            full content. The web truncates it at 150 characters; the API sends it
            whole so the client decides.
        points:
          type: integer
          description: Reward points the RECIPIENT earned. An award stores dollars
            and converts; a peer post stores points directly. Always present (0 when
            none) — the web hides the badge for 0.
          example: 250
        recipient:
          "$ref": "#/components/schemas/RecognitionPerson"
        group_recipients:
          type: array
          nullable: true
          description: Present only on a collapsed GROUP give — every recipient of
            the one recognition. Null for an ordinary single-recipient row.
          items:
            "$ref": "#/components/schemas/RecognitionPerson"
        giver:
          allOf:
          - "$ref": "#/components/schemas/RecognitionPerson"
          nullable: true
          description: Null when `anonymous` is true — anonymity is enforced server-side,
            not merely hidden by the client. For an automated lifecycle award this
            is the "System (Automated)" pseudo-person with a null id; the system principal
            is never exposed. On the `awards_given` list this is the profile's subject.
        anonymous:
          type: boolean
          description: The giver chose to stay unattributed; `giver` is null.
          example: false
        program:
          type: string
          nullable: true
          description: The award program's name. Null for a peer shout-out.
          example: Spot Recognition
        category:
          type: string
          nullable: true
          example: Project Completion
        company_value:
          type: string
          nullable: true
          description: The company value this recognition celebrates.
          example: Teamwork
        company_values:
          type: array
          description: Every company value it celebrates, primary first — up to three.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 4
              name:
                type: string
                example: Customer First
        occurred_at:
          type: string
          format: date-time
          description: When the recognition happened — an award's awarded_at, a post's
            published_at. Every list is ordered by this, newest first.
    RecorderRecording:
      type: object
      description: 'A recording. Lists answer the base fields; single-recording answers
        add the detail fields (`detail: true`). A field marked owner-only or editor-only
        is absent — not null — for anyone else.'
      additionalProperties: true
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - video
          - screenshot
        title:
          type: string
        description:
          type: string
          nullable: true
        status:
          type: string
          enum:
          - uploading
          - processing
          - ready
          - failed
        processing_stage:
          type: string
          nullable: true
        visibility:
          type: string
          enum:
          - private
          - company
          - anyone
        source:
          type: string
          nullable: true
        client_capture_id:
          type: string
          nullable: true
        captured_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        duration_seconds:
          type: number
          nullable: true
        width:
          type: integer
          nullable: true
        height:
          type: integer
          nullable: true
        allow_download:
          type: boolean
        deleted:
          type: boolean
        days_until_purge:
          type: integer
          nullable: true
        error_message:
          type: string
          nullable: true
        web_url:
          type: string
          nullable: true
        public_url:
          type: string
          nullable: true
        thumbnail_url:
          type: string
          nullable: true
        permission:
          type: string
          nullable: true
          enum:
          - owner
          - editor
          - viewer
        can_edit:
          type: boolean
        has_annotations:
          type: boolean
        can_delete:
          type: boolean
          description: The caller owns the recording and the token carries `destructive:recorder`.
        can_download:
          type: boolean
          description: Whether to offer a Download item. Not a gate on the media URL.
        owner:
          allOf:
          - "$ref": "#/components/schemas/RecorderPerson"
          nullable: true
        cta_label:
          type: string
          nullable: true
        cta_url:
          type: string
          nullable: true
        comments_count:
          type: integer
          description: Live comments, replies included.
        cta_clicks_count:
          type: integer
          description: Owner-only.
        views_count:
          type: integer
          description: Owner-only.
        unique_viewers_count:
          type: integer
          description: Owner-only.
        names_hidden:
          type: object
          description: Owner-only, present while the workspace withholds who watched;
            the two play counters then count only the people who may be named.
          properties:
            count:
              type: integer
              nullable: true
            group_count:
              type: integer
              nullable: true
            fewer_than:
              type: integer
            reason:
              type: string
        detail:
          type: boolean
          description: Detail field — true on a single-recording answer.
        url:
          type: string
          nullable: true
          description: Detail field — signed media URL (one hour) once the recording
            is ready.
        content_type:
          type: string
          nullable: true
          description: Detail field.
        byte_size:
          type: integer
          nullable: true
          description: Detail field.
        language:
          type: string
          nullable: true
          description: Detail field.
        summary_markdown:
          type: string
          nullable: true
          description: Detail field.
        chapters:
          type: array
          description: Detail field — sorted, within the recording's length.
          items:
            type: object
            properties:
              start_seconds:
                type: integer
              title:
                type: string
        has_transcript:
          type: boolean
          description: Detail field.
        caption_languages:
          type: array
          description: Detail field — the caption languages on offer.
          items:
            "$ref": "#/components/schemas/RecorderCaptionLanguage"
        shared_with:
          type: array
          description: Detail field, editor-only — the people it is shared with.
          items:
            type: object
            properties:
              user_id:
                type: integer
              name:
                type: string
                nullable: true
              permission:
                type: string
              added_by:
                type: object
                nullable: true
                description: Who added this person (as on `GET …/shares`); null for
                  a share made before the adder was recorded, or whose adder's account
                  was deleted.
                properties:
                  user_id:
                    type: integer
                  name:
                    type: string
        shared_with_rooms:
          type: array
          description: Detail field, editor-only (absent with `shared_with`) — the
            chat rooms that can watch it because it was sent to them.
          items:
            "$ref": "#/components/schemas/RecorderSharedRoom"
        can_manage_sharing:
          type: boolean
          description: Detail field, editor-only — may add a person or Stop a room
            (`DELETE …/chat_rooms/{room_id}`); false means those answer 403 `resharing_not_permitted`.
        metadata:
          type: object
          additionalProperties: true
          description: Detail field — the capture facts the client sent.
        annotations_url:
          type: string
          nullable: true
          description: Detail field — signed URL (one hour) of the annotations bundle,
            for someone who may edit.
    RecorderSharedRoom:
      type: object
      properties:
        room_id:
          type: integer
        name:
          type: string
          description: 'The room as this reader may see it: its name, the other person
            of a direct message, or "A private chat room" / "A direct message" where
            Chat keeps it from them.'
        sent_by:
          type: string
          nullable: true
          description: Who sent the recording there; null when unknown.
        sent_at:
          type: string
          format: date-time
          nullable: true
    RecorderRecycleBinRecording:
      type: object
      description: A recording in the recycle bin as shown to someone who may restore
        it but not watch it (a Recorder admin) — no media, poster or description.
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - video
          - screenshot
        title:
          type: string
        status:
          type: string
          enum:
          - uploading
          - processing
          - ready
          - failed
        visibility:
          type: string
          enum:
          - private
          - company
          - anyone
        deleted:
          type: boolean
        deleted_at:
          type: string
          format: date-time
          nullable: true
        days_until_purge:
          type: integer
          nullable: true
        owner:
          allOf:
          - "$ref": "#/components/schemas/RecorderPerson"
          nullable: true
        permission:
          type: string
          nullable: true
          enum:
          - owner
          - editor
          - viewer
        can_edit:
          type: boolean
        can_delete:
          type: boolean
    RecorderCreatedRecording:
      type: object
      required:
      - recording
      - existing
      properties:
        recording:
          "$ref": "#/components/schemas/RecorderRecording"
        existing:
          type: boolean
          description: True when an existing recording with this `client_capture_id`
            was returned.
    RecorderPerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
          description: Full name, or email when there is no name.
    RecorderCaptionLanguage:
      type: object
      properties:
        language:
          type: string
          description: Language code; pass it as `lang` to `GET /recorder/recordings/{id}/captions`.
        label:
          type: string
        source:
          type: boolean
          description: The recording's own language (its transcript).
    RecorderTranscriptSegment:
      type: object
      properties:
        start:
          type: number
          description: Seconds.
        end:
          type: number
          description: Seconds.
        text:
          type: string
    RecorderMediaType:
      type: string
      enum:
      - video/webm
      - video/mp4
      - video/quicktime
      - image/png
      - image/jpeg
    RecorderUploadLimits:
      type: object
      properties:
        max_bytes:
          type: integer
          description: The workspace's upload cap.
        allowed_types:
          type: array
          items:
            "$ref": "#/components/schemas/RecorderMediaType"
    RecorderPreferences:
      type: object
      properties:
        default_visibility:
          type: string
          enum:
          - private
          - company
          - anyone
          description: The audience a new capture gets now — the caller's own choice
            when the workspace allows it, else the workspace default.
        own_default_visibility:
          type: string
          nullable: true
          enum:
          - private
          - company
          - anyone
          description: The caller's own stored choice; null when none, or when the
            workspace no longer allows it.
        default_transcribe:
          type: boolean
        visibility_enforced:
          type: boolean
          description: The workspace default applies to everyone; a personal choice
            waits.
    RecorderConfig:
      type: object
      properties:
        enabled:
          type: boolean
          example: true
        settings:
          type: object
          properties:
            allow_downloads:
              type: boolean
            max_upload_size_mb:
              type: integer
            default_visibility:
              type: string
              enum:
              - private
              - company
              - anyone
            public_links_enabled:
              type: boolean
            public_link_max_days:
              type: integer
              description: Longest a public link may last, in days; 0 means no limit.
            transcription_enabled:
              type: boolean
            ai_summary_enabled:
              type: boolean
        preferences:
          "$ref": "#/components/schemas/RecorderPreferences"
        capabilities:
          type: object
          description: What this caller, with this token, may do.
          properties:
            upload:
              type: boolean
            share:
              type: boolean
            public_links:
              type: boolean
              description: The token may write, public links are on, and the caller
                may share externally.
            send_to_chat:
              type: boolean
              description: The token carries `write:recorder` and `write:chat`, and
                Chat is open to the caller.
            handoffs:
              type: array
              description: The "Use this recording" hand-offs the caller may start.
              items:
                type: string
                enum:
                - training
                - sop
                - library
                - ticket
                - bug
                - practice
    RecorderPusherAuthRequest:
      type: object
      required:
      - socket_id
      - channel_name
      properties:
        socket_id:
          type: string
          description: The Pusher socket id (`<digits>.<digits>`).
        channel_name:
          type: string
          description: "`private-user-recorder-{userId}-business-{businessId}`, with
            the caller's own ids."
    RecorderSentToChat:
      type: object
      properties:
        status:
          type: string
          enum:
          - queued
          - duplicate
        recording_id:
          type: integer
        room_id:
          type: integer
        message_id:
          type: integer
          nullable: true
          description: The message already posted (`duplicate`); null while `queued`.
        link:
          type: string
          enum:
          - company
          - anyone
        url:
          type: string
          description: The link the card carries.
    RecorderShareLink:
      type: object
      properties:
        id:
          type: integer
        token:
          type: string
        url:
          type: string
        embed_url:
          type: string
        label:
          type: string
          nullable: true
        auto:
          type: boolean
          description: Whether this is the recording's automatic "anyone with the
            link" link.
        expires_at:
          type: string
          format: date-time
          nullable: true
        password:
          type: boolean
          description: Whether the link asks for a password.
        require_email:
          type: boolean
        allow_download:
          type: boolean
          description: Whether a download works through this link now (the link's
            own switch and the recording's).
        max_views:
          type: integer
          nullable: true
        view_count:
          type: integer
        last_viewed_at:
          type: string
          format: date-time
          nullable: true
        revoked_at:
          type: string
          format: date-time
          nullable: true
        status:
          type: string
          enum:
          - Active
          - Expired
          - View limit reached
          - Revoked
        created_at:
          type: string
          format: date-time
    RecorderShare:
      type: object
      properties:
        id:
          type: integer
          description: The share's id (`share_id`).
        user_id:
          type: integer
        name:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        permission:
          type: string
          enum:
          - viewer
          - editor
        added_by:
          type: object
          nullable: true
          description: Who added this person to the roster. Null for a share made
            before the adder was recorded, or whose adder's account was deleted. A
            permission change keeps the original adder.
          properties:
            user_id:
              type: integer
            name:
              type: string
    RecorderShareRoster:
      type: object
      required:
      - recording_id
      - shares
      - total
      properties:
        recording_id:
          type: integer
        shares:
          type: array
          items:
            "$ref": "#/components/schemas/RecorderShare"
        total:
          type: integer
          description: People it is shared with, across every page.
        next_cursor:
          type: integer
          nullable: true
          description: The `cursor` (an offset) for the next page of `GET …/shares`;
            null on the last page.
    RecorderShareRosterAfterShare:
      allOf:
      - "$ref": "#/components/schemas/RecorderShareRoster"
      - type: object
        required:
        - existing
        properties:
          existing:
            type: boolean
            description: True when the person was already on the list.
    RecorderComment:
      type: object
      properties:
        id:
          type: integer
        body:
          type: string
          description: The text, with @mentions shown as names.
        body_html:
          type: string
          description: The text as HTML, with @mentions rendered.
        anchor_seconds:
          type: integer
          nullable: true
          description: The moment in the recording it is pinned to.
        parent_comment_id:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        author:
          allOf:
          - "$ref": "#/components/schemas/RecorderPerson"
          nullable: true
        can_delete:
          type: boolean
          description: The caller wrote it or owns the recording, the recording is
            out of the recycle bin, and the token carries `destructive:recorder`.
        reply_count:
          type: integer
          description: Live replies under it — may be more than `replies` holds.
        replies_truncated:
          type: boolean
          description: True when `replies` holds fewer than `reply_count`.
        replies:
          type: array
          description: Up to 50 replies, oldest first (a reply's own `replies` is
            always empty).
          items:
            "$ref": "#/components/schemas/RecorderComment"
    RecruitingCandidateSummary:
      type: object
      properties:
        id:
          type: integer
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        status:
          type: string
        source:
          type: string
          nullable: true
        linkedin_url:
          type: string
          nullable: true
        current_title:
          type: string
          nullable: true
        current_company:
          type: string
          nullable: true
        profile_picture_url:
          type: string
          nullable: true
        job:
          type: object
          properties:
            id:
              type: integer
              nullable: true
            title:
              type: string
              nullable: true
        profile_url_in_ats:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RfpAnswerInput:
      type: object
      properties:
        question:
          type: string
        answer:
          type: string
        category:
          type: string
          enum:
          - technical
          - compliance
          - commercial
          - delivery
          - other
        tags:
          type: array
          items:
            type: string
        active:
          type: boolean
    RfpAnswer:
      type: object
      properties:
        id:
          type: integer
        question:
          type: string
        answer:
          type: string
        category:
          type: string
        tags:
          type: array
          items:
            type: string
        active:
          type: boolean
        stale:
          type: boolean
          description: Not marked reviewed (or, if never reviewed, its question or
            answer text not changed) within the workspace's staleness window (Settings
            > Answer freshness, 12 months by default)
        usage_count:
          type: integer
        last_used_at:
          type: string
          format: date-time
          nullable: true
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
        owner:
          "$ref": "#/components/schemas/RfpPerson"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RfpPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    RfpResponseSummary:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - draft
          - on_hold
          - in_review
          - approved
          - submitted
          - declined
        status_label:
          type: string
        locked:
          type: boolean
          description: Approved or submitted — section drafts refuse edits
        due_on:
          type: string
          format: date
          nullable: true
        responsible:
          "$ref": "#/components/schemas/RfpPerson"
        section_count:
          type: integer
        open_gap_count:
          type: integer
        assessment_id:
          type: integer
          nullable: true
        outcome:
          type: string
          nullable: true
          enum:
          - won
          - lost
          -
          description: How a submitted bid ended, as recorded in RFP Desk or by its
            proposal; null otherwise.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    RfpResponse:
      allOf:
      - "$ref": "#/components/schemas/RfpResponseSummary"
      - type: object
        properties:
          notes:
            type: string
            nullable: true
          internal_target_on:
            type: string
            format: date
            nullable: true
          approved_at:
            type: string
            format: date-time
            nullable: true
          submitted_at:
            type: string
            format: date-time
            nullable: true
          approval_blocker:
            type: string
            nullable: true
            enum:
            - no_questions
            - input
            - review
            - specialist_review
            - deliverables
            -
            description: The first thing that still stands between the response and
              approval, in the order the approval gate checks it — no questions yet,
              questions still needing input, sections unreviewed, a specialist sign-off,
              required checklist items not ticked. Null when nothing does, or once
              the response is approved, submitted or declined.
          required_items_outstanding:
            type: array
            description: Labels of required submission-checklist items nobody has
              ticked yet.
            items:
              type: string
          sections:
            type: array
            items:
              "$ref": "#/components/schemas/RfpSection"
    RfpSection:
      type: object
      properties:
        id:
          type: integer
        position:
          type: integer
        kind:
          type: string
          description: requirement, or a narrative kind (cover letter, executive summary…)
        requirement:
          type: string
        category:
          type: string
          nullable: true
        mandatory:
          type: boolean
        draft:
          type: string
          nullable: true
        draft_version:
          type: string
          description: Digest of the answer's words — send it back as `base_draft_version`
            when answering the section.
        grounded_on:
          type: string
          nullable: true
          enum:
          - library
          - human
          - capabilities
          - knowledge
          - none
        grounding:
          type: string
          description: Human-readable provenance
        needs_input:
          type: boolean
        reviewed:
          type: boolean
        owner:
          "$ref": "#/components/schemas/RfpPerson"
        owner_active:
          type: boolean
          nullable: true
          description: Whether the section's owner is still an active member of the
            workspace. False when they have left — the section keeps their name until
            someone sends it on (RFP Desk hands nothing over at a departure). Null
            when the section has no owner.
        waiting_on:
          allOf:
          - "$ref": "#/components/schemas/RfpPerson"
          nullable: true
          description: Who an open question is with — the person its open routed task
            is assigned to. Differs from `owner` when the task was handed on in Tasks
            to someone the bid's routing rule did not let become its owner; that person,
            not the owner, is answering it. Null when the section is answered or no
            task is open for it.
        owed_by:
          allOf:
          - "$ref": "#/components/schemas/RfpPerson"
          nullable: true
          description: Who owes an open question now, by the rule the bid page, the
            phone and the deadline reminder use — the holder of its open routed task,
            else its owner. Null when the section is answered, has no owner, or its
            request was withdrawn or declined and not sent again (nobody is waiting
            on it).
        updated_at:
          type: string
          format: date-time
        reused_library_answer:
          type: string
          nullable: true
          enum:
          - stale
          - archived
          -
          description: The library answer this section reuses is past the workspace's
            review window (`stale`) or has since been archived (`archived` — rewrite
            the section before sending). Null when neither.
    RfpAssessmentSummary:
      type: object
      properties:
        id:
          type: integer
        issuer:
          type: string
          nullable: true
        recommendation:
          type: string
          nullable: true
          enum:
          - go
          - conditional
          - no_bid
          description: null when the assessment ran without a capability profile or
            found no requirements
        score:
          type: integer
          nullable: true
        hard_gated:
          type: boolean
        terms_screened:
          type: boolean
          nullable: true
        response_id:
          type: integer
          nullable: true
          description: The RFP response drafted from this assessment, if any
        run_by:
          type: string
          nullable: true
          description: Email of the person who ran it (null when they have no email
            address)
        run_by_user_id:
          type: integer
          nullable: true
          description: User id of the person who ran it
        created_at:
          type: string
          format: date-time
    RfpAssessment:
      allOf:
      - "$ref": "#/components/schemas/RfpAssessmentSummary"
      - type: object
        properties:
          company_name:
            type: string
            nullable: true
          rfp_meta:
            type: object
            properties:
              issuer:
                type: string
                nullable: true
              estimated_value:
                type: string
                nullable: true
              submission_deadline:
                type: string
                nullable: true
          no_profile:
            type: boolean
          no_requirements:
            type: boolean
          input_truncated:
            type: boolean
          breakdown:
            type: array
            items:
              type: object
          requirements:
            type: array
            items:
              type: object
              properties:
                text:
                  type: string
                category:
                  type: string
                mandatory:
                  type: boolean
                we_can_meet:
                  type: string
                evidence:
                  type: string
          unmet_mandatory:
            type: array
            items:
              type: object
          disqualifier_hits:
            type: array
            items:
              type: object
    SearchFilters:
      type: object
      description: The filters applied, in the page's own params (`when` is echoed
        as `since`).
      properties:
        in:
          type: string
          example: policy_hub
        since:
          type: string
          enum:
          - 30d
          - 90d
          - year
        mine:
          type: string
          enum:
          - '1'
    SearchFilterOption:
      type: object
      properties:
        key:
          type: string
          nullable: true
          description: The value to send; null means "no filter" (Everywhere / Any
            time).
        title:
          type: string
        selected:
          type: boolean
        category:
          type: object
          description: 'In list only: the area''s group (Search::Areas — the grouping
            Settings › What''s in results uses). Absent on Everywhere and on an area
            no group claims.'
          properties:
            key:
              type: string
              enum:
              - people
              - content
              - work
              - comms
              - platform
            title:
              type: string
              example: Content & knowledge
        count:
          type: integer
          description: 'In list only: what picking this area would show — its matches
            under the current When and Only mine, before In (0 when it found nothing).
            On Everywhere, every match including external sources. Absent when unknown:
            under an In filter the engine ran that one area only.'
    SearchSection:
      type: object
      properties:
        key:
          type: string
          example: policy_hub
        title:
          type: string
          example: Policies
        count:
          type: integer
          description: Rows in this section (`items` holds at most `limit` of them).
        total:
          type: integer
          description: The area's full match count, only when it capped its rows ("8
            of 46"). On `home.for_you`, the full number owed (policies to acknowledge,
            tasks due) when the home shows only the first few.
        see_all_url:
          type: string
          description: The app's own search for this query, where it has one. On `home.for_you`,
            the app's own list of everything owed (Policy Hub's pending acknowledgments,
            My Tasks, open shifts).
        see_all_title:
          type: string
        also_in:
          type: boolean
          description: True when the page folds this section into its "Also in" block
            — a late section (after the first six) holding one row, when at least
            five such sections exist. People never fold.
        items:
          type: array
          items:
            "$ref": "#/components/schemas/SearchRow"
    SearchRow:
      type: object
      description: One result. Fields a row has no value for are omitted.
      properties:
        id:
          description: The record's id within its area (people rows carry the user
            id).
          oneOf:
          - type: integer
          - type: string
        title:
          type: string
        url:
          type: string
        subtitle:
          type: string
        icon:
          type: string
        section:
          type: string
          description: The area a ranked row (`best_match`, `top`) came from.
        section_title:
          type: string
        updated_at:
          type: string
          format: date-time
        owner:
          type: string
        owner_id:
          type: integer
        version:
          type: string
        event_dated:
          type: boolean
          description: True when `updated_at` is when the thing happens (a shift's
            start), not when it was last edited.
        outdated:
          type: boolean
        takes_effect_on:
          type: string
          format: date
        semantic:
          type: boolean
          description: Found by meaning rather than by its words.
        promoted:
          type: boolean
          description: An admin-promoted result for this query.
        external_host:
          type: string
        snippet:
          type: string
          description: An external source's excerpt.
        passage:
          type: string
          maxLength: 320
          description: Where the row matched, as the results page draws it under the
            row — the sentence a Messages result's word is in (its subject, for a
            thread matched on it), a page's matched paragraph, or the record's summary
            (a summary's markdown is stripped). Text to draw as-is, never HTML; at
            most 320 characters.
        passage_term:
          type: string
          description: The words to mark in `passage` (case-insensitive), as the web
            marks them. Sent only with a passage, and only when it is at least two
            characters.
        score:
          type: number
        reasons:
          type: array
          items:
            type: string
        applies_chip:
          type: string
          description: A policy row's applicability chip, e.g. "Applies to you · Sales"
            or "For California employees".
        status:
          type: string
          description: What the caller already did with the row, in place of its action
            — "Pick-up requested · pending approval", "You're registered", "On the
            waitlist" or "You attended". Absent otherwise.
        actions:
          type: array
          description: 'What the caller can do from the row (e.g. acknowledge, open).
            Each is `{label, url, method, primary, confirm}`. A `get` action is a
            page to open. A `post` action is the web app''s own form endpoint: it
            needs a signed-in browser session and its CSRF token, not a bearer token
            — open the row''s own `url` in a web view, or call that app''s own API
            for the action.'
          items:
            type: object
            additionalProperties: true
        person:
          allOf:
          - "$ref": "#/components/schemas/SearchPersonCard"
          description: On every People row — in the People section and as the best
            match or a top row.
    SearchPersonCard:
      type: object
      description: A person's card facts, on rows in the `people` section and on experts
        — loaded for the whole page at once. Fields the viewer may not see are omitted.
      properties:
        job_title:
          type: string
        department:
          type: string
        location:
          type: string
        manager_name:
          type: string
        email:
          type: string
        phone:
          type: string
        presence:
          type: string
          description: On shift / clocked in, when known.
        skills:
          type: array
          items:
            type: string
        disambiguator:
          type: string
          description: Set only when another card on the page reads identically.
        deactivated:
          type: boolean
        booking_url:
          type: string
          description: The person's public booking page, when they have one.
    SearchSavedSearch:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        query:
          type: string
        filters:
          "$ref": "#/components/schemas/SearchFilters"
        alerting:
          type: boolean
        alert_cadence:
          type: string
          enum:
          - 'off'
          - daily
          - weekly
        cadence_label:
          type: string
          example: Weekly
        last_alerted_at:
          type: string
          format: date-time
          description: When the last alert went out. Absent until one has (a search
            saved with alerts off never has one).
        last_match_count:
          type: integer
          description: The new results that last alert reported ("4 new"). Only alongside
            `last_alerted_at`.
          example: 4
        last_checked_at:
          type: string
          format: date-time
          description: When the alert pass last reached this search, whether or not
            it found any — "Checked 2 hours ago · nothing new yet". A daily alert
            checked days ago is not running. A pass also stamps it when it skips a
            muted alert, so read it beside `alerts_muted`.
        alerts_muted:
          type: string
          enum:
          - mine
          - organisation
          description: Present on an alerting search whose alerts will not be delivered
            — `mine` when the person muted Search notices in their notification settings,
            `organisation` when the organisation's settings turn them off.
        area_closed:
          type: string
          enum:
          - excluded
          - no_access
          description: Present on an alerting search narrowed (`filters.in`) to an
            area the organisation switched off in Search settings (`excluded`) or
            whose app the person can no longer open (`no_access`); such a search finds
            nothing on any pass, so it is not checked.
        created_at:
          type: string
          format: date-time
    TicketActivityEntry:
      type: object
      description: A comment (type "comment", with the TicketComment fields) or a
        lifecycle milestone (type "milestone").
      properties:
        type:
          type: string
          enum:
          - comment
          - milestone
        at:
          type: string
          format: date-time
        id:
          type: integer
        kind:
          type: string
          description: Milestones only — submitted, assigned, priority_changed, resolved,
            …
        headline:
          type: string
          description: Milestones only — the line as this caller may read it
        note:
          type: string
          nullable: true
        icon:
          type: string
          description: Milestones only — a Font Awesome class
        staff_only:
          type: boolean
        content:
          type: string
          description: Comments only
        internal:
          type: boolean
          description: Comments only
        attachments:
          type: array
          items:
            "$ref": "#/components/schemas/ServiceDeskAttachment"
    ServiceDeskAttachment:
      type: object
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        url:
          type: string
          description: Absolute download URL; expires after one hour, so fetch it
            again from the ticket rather than storing it.
    TicketBillingProject:
      type: object
      properties:
        id:
          type: integer
        code:
          type: string
          nullable: true
        name:
          type: string
          nullable: true
        label:
          type: string
          description: Code and name together, as the web shows it
    TicketPermissions:
      type: object
      description: What the caller may do on this ticket right now (role and ticket
        state together) — the same rules the web ticket page draws its buttons from.
        Draw a control only when its flag is true.
      properties:
        can_edit:
          type: boolean
        can_assign:
          type: boolean
          description: Assign or transfer (POST /api/v1/service_desk/{id}/assign)
            — false on a closed, cancelled, rejected or pending-approval ticket
        can_claim:
          type: boolean
          description: Take this unowned ticket yourself (POST /api/v1/service_desk/{id}/claim)
            — Service Desk staff, from a queue they are on (admins from any), never
            their own ticket under prevent-self-service
        can_comment:
          type: boolean
          description: May post a comment the requester can see (POST /api/v1/service_desk/{id}/comments)
            — false on a rejected or cancelled ticket, and for a token without write:service_desk
        can_comment_internal:
          type: boolean
          description: 'May post a comment with `internal: true` (staff or the assignee)
            — false on a rejected or cancelled ticket, and for a token without write:service_desk'
        can_approve:
          type: boolean
        can_reject:
          type: boolean
        can_cancel:
          type: boolean
        can_reinstate:
          type: boolean
        can_rate:
          type: boolean
          description: The requester may submit a satisfaction rating (once, after
            the ticket is resolved or closed)
        can_escalate:
          type: boolean
        can_de_escalate:
          type: boolean
        can_merge:
          type: boolean
          description: Merge this ticket into another (POST /api/v1/service_desk/{id}/merge)
            — false once it is merged, cancelled, rejected, resolved or closed
        can_clone:
          type: boolean
          description: Start a copy (GET /api/v1/service_desk/{id}/clone) — never
            on a grievance
        can_log_time:
          type: boolean
        can_manage_parts:
          type: boolean
          description: May request, cancel, dismiss and return parts (staff, with
            the Procurement app on and open to them)
        can_view_participants:
          type: boolean
        can_add_participants:
          type: boolean
        allowed_status_actions:
          type: array
          description: The `action_type` values PATCH /api/v1/service_desk/{id}/status
            will accept from this caller now.
          items:
            type: string
            enum:
            - start_progress
            - revert_to_assigned
            - resolve
            - close
            - reopen
            - approve
            - reject
            - cancel
            - reinstate
    ServiceDeskIntakeForm:
      type: object
      description: Form-wide New Request rules for this tenant and viewer.
      properties:
        priority:
          type: object
          properties:
            shown:
              type: boolean
              description: |
                Draw the priority picker. When false, leave priority out: either the
                tenant sets it (levels off, system / agent mode) or it is hidden from
                requesters.
            default:
              type: string
              nullable: true
              example: medium
            hidden_note:
              type: string
              nullable: true
              description: Sentence to show in place of the picker when it is hidden.
            urgency_reason_required_for:
              type: array
              items:
                type: string
              description: Priorities that need a "Why is this urgent?" answer. Empty
                when the picker is hidden.
              example:
              - high
              - critical
        approval_cost_threshold:
          type: number
          nullable: true
          description: Show "Amounts over <this> require approval" under Estimated
            cost — a higher cost sends the request for approval.
          example: 500.0
        anonymous_allowed:
          type: boolean
          description: Offer the "Submit anonymously" switch.
        kb:
          type: object
          properties:
            suggest:
              type: boolean
              description: Suggest knowledge-base articles while the request is typed
                (`GET /service_desk/kb/search`).
            require_acknowledgement:
              type: boolean
              description: Ask the requester to confirm the suggestions did not help
                before submitting.
    ServiceDeskIntakeTypeRules:
      type: object
      description: One request type's New Request rules. Present on `service_types`
        entries only.
      properties:
        description_required:
          type: boolean
        requires_approval:
          type: boolean
          description: The type always needs approval. A high `estimated_cost` can
            also require it.
        title_template:
          type: string
          nullable: true
          example: 'Equipment Request: [Item Name]'
        description_template:
          type: string
          nullable: true
        fields:
          type: object
          description: Visibility and required rules for the optional layout fields.
          properties:
            location:
              "$ref": "#/components/schemas/ServiceDeskIntakeFieldRule"
            due_at:
              "$ref": "#/components/schemas/ServiceDeskIntakeFieldRule"
            estimated_cost:
              "$ref": "#/components/schemas/ServiceDeskIntakeFieldRule"
            business_justification:
              "$ref": "#/components/schemas/ServiceDeskIntakeFieldRule"
        custom_fields:
          type: array
          description: The type's custom fields in the admin's layout order.
          items:
            "$ref": "#/components/schemas/ServiceDeskCustomFieldDefinition"
    ServiceDeskIntakeFieldRule:
      type: object
      properties:
        visible:
          type: boolean
        required:
          type: boolean
    ServiceDeskCustomFieldDefinition:
      type: object
      description: The same definition the web form draws from.
      properties:
        field_name:
          type: string
          description: The key to answer it under in `ticket.custom_fields`.
        label:
          type: string
        field_type:
          type: string
          enum:
          - text
          - textarea
          - number
          - email
          - url
          - select
          - multiselect
          - radio
          - checkbox
          - date
          - info_block
          description: "`info_block` is display-only text."
        required:
          type: boolean
        placement:
          type: string
          enum:
          - before_title
          - after_title
        options:
          type: object
          additionalProperties: true
          description: |
            `choices` (array of strings) for select / multiselect / radio, plus
            optional `visibility_condition` / `required_condition` rules the web
            form evaluates on the client.
        placeholder:
          type: string
          nullable: true
        help_text:
          type: string
          nullable: true
        validation_rules:
          type: object
          nullable: true
          additionalProperties: true
        dynamic_source:
          type: string
          nullable: true
          description: "`mangoapps_system_user` / `_location` / `_department`: choose
            from that directory and send the display name."
        dynamic_source_searchable:
          type: boolean
          description: The choices are not listed (people search) — look the person
            up and send their name.
        internal_only:
          type: boolean
          description: Staff-only field; never sent to a requester.
    MyTicketsWidgetResponse:
      type: object
      required:
      - success
      - user_role
      - tabs
      properties:
        success:
          type: boolean
          example: true
        user_role:
          type: string
          enum:
          - member
          - agent
          - manager
          - admin
          - super_admin
          description: Effective role of the current user in this business
        tabs:
          type: array
          description: Ordered array of applicable tabs (role-dependent)
          items:
            "$ref": "#/components/schemas/TicketTab"
    TicketTab:
      type: object
      required:
      - key
      - label
      - total_count
      - tickets
      properties:
        key:
          type: string
          enum:
          - created_by_me
          - assigned_to_me
          - pending_approval_for_me
          description: Machine-readable tab identifier
        label:
          type: string
          description: Display label in the language the client asks for (`locale`,
            else `Accept-Language`, else the caller's own language). Display only
            — branch on `key`.
          example: Created by Me
        total_count:
          type: integer
          description: Total number of tickets in this tab (for badge/View All)
          example: 24
        tickets:
          type: array
          description: Preview tickets (at most per_tab items)
          items:
            "$ref": "#/components/schemas/SupportTicketSummary"
    AbsenceReason:
      type: object
      description: |
        One tenant-configured absence reason code, as offered by
        `GET /absence_reasons`. Post `id` back as
        `absence_report[absence_reason_code_id]` when filing the report.
      required:
      - id
      - code
      - name
      - label
      - active
      properties:
        id:
          type: integer
        code:
          type: string
          description: 'Stable per-tenant identifier (e.g. `sick`, `running_late`).
            Renaming the reason changes `name`/`label`, never this.

            '
        name:
          type: string
          description: The tenant's own label — the exact string the web picker shows.
        label:
          type: string
          description: Alias of `name`. Both carry the same tenant string.
        description:
          type: string
          nullable: true
        category:
          type: string
          enum:
          - general
          - illness
          - personal
          - transportation
          - family
          - other
        category_label:
          type: string
          nullable: true
          description: Display form of `category` (e.g. `illness` -> "Illness/Medical").
        requires_documentation:
          type: boolean
          description: |
            ADVISORY ONLY. Nothing enforces it — no absence surface has an
            attachment field — so render it as guidance ("documentation may be
            required"). Do not gate submission on it, and do not promise an
            upload step. The web form deliberately stopped appending
            "(Requires Documentation)" to the option text for this reason.
        documentation_threshold_days:
          type: integer
          description: Absence length past which the tenant expects documentation.
            0 = none.
        requires_manager_approval:
          type: boolean
        advance_notice_required_days:
          type: integer
          description: Notice the tenant's policy expects for this reason. 0 = none.
        requires_estimated_arrival:
          type: boolean
          description: 'Collect an estimated arrival time for this reason — it is
            a DELAY, not an absence. True for the code the server treats as "running
            late", which is also what decides whether the ETA is shown to the manager,
            so key the field on this rather than on the label.

            '
        is_paid:
          type: boolean
        active:
          type: boolean
          description: 'False only in an `include_inactive=true` response — a retired
            code, kept so an existing report can still be named. Never offer one in
            a new-report picker.

            '
    ClockInBrief:
      type: object
      description: |
        "Before you start" — `GET /attendance_records/{id}/clock_in_brief`.
        `since` is the caller's last clock-out before this punch (at most 14
        days back; 7 when they have never clocked out). `degraded` names any
        part that failed to load, so a short brief is never read as complete.

        Since Knowledge Layer E2 the brief carries the ONE "Waiting on you" (the
        same list as `waiting_on_you` on `GET /home`): `waiting_on_you.total`,
        then its two halves — `to_decide` and `owed` ("To acknowledge"). At most
        five items show across both, "To decide" first; "See all N" is the
        Inbox's Waiting on you. `work` ("Your tasks") no longer repeats either
        half, and `before_you_start` is the place's own message for this shift.
      properties:
        since:
          type: string
          format: date-time
        last_clock_out_at:
          type: string
          format: date-time
          nullable: true
          description: |
            The caller's real last clock-out before this punch — null on a
            first-ever punch, and earlier than `since` when the window was
            capped at 14 days. Say "since you last clocked out" only when it
            equals `since`; otherwise the window is "the last N days".
        waiting_on_you:
          type: object
          description: The whole of Waiting on you — `to_decide.total` plus `owed.total`.
          properties:
            total:
              type: integer
        to_decide:
          type: object
          description: |
            "To decide" — what the caller decides (the approvals registry:
            people's requests, AI suggestions, …). `items` is the first up to 5,
            most urgent first; `total` and `counts_by_source` the whole set.
          properties:
            total:
              type: integer
            counts_by_source:
              type: object
              additionalProperties:
                type: integer
            items:
              type: array
              maxItems: 5
              items:
                type: object
                description: |
                  One "To decide" row of Waiting on you (Knowledge Layer E2) — a request
                  or suggestion whose decision is the caller's, from the approvals
                  registry. Decided with `POST /api/v1/home/waiting_on_you/decide`
                  (`source`, `id`; Knowledge Layer C3) or where `url` links;
                  `can_approve` / `can_decline` say which verbs will be taken right now —
                  from an API token, what that decide door takes from it: neither without
                  `write:approvals`, and no delete-level verdict without its area's delete
                  scope (`GET /home` and the clock-in brief alike).
                properties:
                  source:
                    type: string
                    description: The approvals source (queue) key.
                    example: leave_requests
                  id:
                    type: integer
                    description: The record's id in that source.
                  kind_label:
                    type: string
                    example: Time off
                  title:
                    type: string
                    description: Subject and summary as one line.
                    example: Ana Morales — Vacation · 9 Oct – 10 Oct
                  subtitle:
                    type: string
                    nullable: true
                  requested_at:
                    type: string
                    format: date-time
                    nullable: true
                  urgency:
                    type: string
                    enum:
                    - overdue
                    - soon
                    - normal
                  urgency_label:
                    type: string
                    nullable: true
                    description: The words for `urgency` ("Overdue", "Due soon", or
                      the source's own); null for `normal`.
                  approve_label:
                    type: string
                    example: Approve
                  decline_label:
                    type: string
                    example: Decline
                  can_approve:
                    type: boolean
                  can_decline:
                    type: boolean
                  decline_needs_reason:
                    type: boolean
                  approve_question:
                    type: string
                    nullable: true
                    description: A question to ask the person before sending `approve`
                      (for example when they are approving their own request); null
                      when Approve asks nothing.
                  decline_question:
                    type: string
                    nullable: true
                    description: A question to ask before sending `decline` (a refusal
                      that takes no reason and cannot be undone); null when Decline
                      asks nothing.
                  url:
                    type: string
                    nullable: true
                    description: Where it is decided (the /m/ page on the API).
        owed:
          type: object
          description: |
            "To acknowledge" — everything waiting on the caller's confirmation
            (every kind). `items` fills what `to_decide` leaves of the five shown
            (overdue first); `total` and `counts_by_kind` are the whole set — the
            rest is the Inbox's Waiting on you.
          properties:
            total:
              type: integer
            counts_by_kind:
              type: object
              additionalProperties:
                type: integer
            items:
              type: array
              items:
                type: object
                properties:
                  kind:
                    type: string
                    example: must_read_post
                  id:
                    type: integer
                  title:
                    type: string
                  kind_label:
                    type: string
                  url:
                    type: string
                    description: The /m/ page where it is read and confirmed.
                  asked_at:
                    type: string
                    format: date-time
                    nullable: true
                  due_at:
                    type: string
                    format: date-time
                    nullable: true
                  overdue:
                    type: boolean
                  action_label:
                    type: string
                  open_label:
                    type: string
                    description: The words for a button that opens the item instead
                      ("Read and acknowledge" for a must-read post or broadcast).
                  confirm_in_chat:
                    type: boolean
                    description: True when it can be confirmed in place (`POST …/clock_in_brief/confirm`).
        shift_changes:
          type: object
          properties:
            total:
              type: integer
            items:
              type: array
              description: Soonest shift first, at most 10.
              items:
                type: object
                properties:
                  kind:
                    type: string
                    enum:
                    - added
                    - changed
                    - cancelled
                    - removed
                  label:
                    type: string
                    example: Time changed
                  shift_id:
                    type: integer
                  name:
                    type: string
                  starts_at:
                    type: string
                    format: date-time
                  ends_at:
                    type: string
                    format: date-time
                    nullable: true
                  time_zone:
                    type: string
                  location:
                    type: string
                    nullable: true
                  changed_at:
                    type: string
                    format: date-time
                    nullable: true
                  when:
                    type: string
                    example: Tue 30 Sep, 9:00 AM – 5:00 PM
                  was:
                    type: string
                    nullable: true
                    example: was 8:00 AM – 4:00 PM
        work:
          type: object
          nullable: true
          description: |
            "Your tasks" — My Day's one list less its must-reads and approvals
            (both are in Waiting on you); `items` is the top 5.
          properties:
            total:
              type: integer
            items:
              type: array
              items:
                type: object
                properties:
                  source:
                    type: string
                    example: tasks
                  title:
                    type: string
                  subtitle:
                    type: string
                    nullable: true
                  due_at:
                    type: string
                    format: date-time
                    nullable: true
                  overdue:
                    type: boolean
                  url:
                    type: string
                    nullable: true
                  action_label:
                    type: string
        news:
          type: object
          description: What arrived since `since` (counts only).
          properties:
            feed:
              type: integer
            messages:
              type: integer
            chat:
              type: integer
              description: Chat rooms with unread messages since `since`.
            mango_cs:
              type: integer
            mango_cs_news:
              type: integer
            recognition:
              type: integer
            summary:
              type: string
              nullable: true
              example: 4 new posts and 2 conversations with new messages
        before_you_start:
          type: object
          nullable: true
          description: |
            The place's own "Before you start" message for this shift (the
            post-clock-in Comms placement), part of the brief rather than a
            second card. Null when there is none, or no open punch.
          properties:
            placement_id:
              type: integer
            title:
              type: string
              nullable: true
            from:
              type: string
              nullable: true
              description: The sender's name.
            requires_acknowledgment:
              type: boolean
        degraded:
          type: array
          items:
            type: string
    CommsPlacement:
      type: object
      description: |
        A placement on the one Communications placement engine — a message
        booked into a place (a shift, Top of Home, a store's or department's
        pin). Read-only: open the message with `GET /broadcasts/{id}` or
        `GET /feeds/{id}` (`message_type` + `broadcast_id` / `feed_id` say
        which), acknowledge it through that message's own acknowledge endpoint,
        dismiss it with `POST /broadcasts/{id}/placements/{placement_id}/dismiss`
        or `POST /feeds/{id}/placements/{placement_id}/dismiss`.

        Since Knowledge Layer Phase 3 (E7) a placement may hold a feed post, its
        window is a datetime window (`starts_at` / `ends_at`; `starts_on` /
        `ends_on` stay for older clients), and `pinned_label` is the line Home
        shows above it.
      properties:
        id:
          type: integer
        message_type:
          type: string
          enum:
          - broadcast
          - feed
        broadcast_id:
          type: integer
          nullable: true
        feed_id:
          type: integer
          nullable: true
        slot_key:
          type: string
          description: The place it is booked into.
          example: shifts.shift_detail
        slot_label:
          type: string
          nullable: true
        kicker:
          type: string
          nullable: true
        title:
          type: string
          description: The message's title (a broadcast in the reader's language).
        body:
          type: string
          description: Its text below the title, as plain text.
        cta_label:
          type: string
          description: The button's words (the placement's own, else the default).
        requires_acknowledgment:
          type: boolean
        acknowledged:
          type: boolean
          nullable: true
          description: Whether the caller acknowledged it; null when it asks for none.
        sender_name:
          type: string
          example: Communications
        pinned_label:
          type: string
          description: |
            "Pinned by … until …" — who placed it, the department when it is a
            department's pin, and when it ends in the reader's time zone (a time
            today, a weekday within the week, else a date).
          example: Pinned by Luis Ortega until Thu
        location_id:
          type: integer
          nullable: true
          description: The store a store pin is for.
        organizational_department_id:
          type: integer
          nullable: true
          description: The department a department pin is for.
        starts_at:
          type: string
          format: date-time
          nullable: true
        ends_at:
          type: string
          format: date-time
          nullable: true
        starts_on:
          type: string
          format: date
          nullable: true
          description: Deprecated — the date of `starts_at`, for older clients.
        ends_on:
          type: string
          format: date
          nullable: true
          description: Deprecated — for older clients.
    RestBreakConfirmation:
      type: object
      description: |
        A rest-break question waiting for the caller, put off from an earlier shift
        to this clock-in. Answer it at `POST /break_attestations/{id}/answer`.
      properties:
        id:
          type: integer
        work_date:
          type: string
          format: date
          description: The day the question is about.
        work_day_label:
          type: string
          example: Monday, October 6
        statement:
          type: string
          description: The statement the person certifies, worded for that day.
        questions:
          type: array
          description: The questions to ask, in order, each with its answer options.
          items:
            type: object
            additionalProperties: true
    UndoClockOutWindow:
      type: object
      description: |
        Server-truth state of the Undo Clock-Out window, as carried by
        `POST /attendance_records/{id}/check_out`,
        `POST /attendance_records/{id}/undo_clock_out` and
        `GET /attendance_records/status`. The same four keys are always present;
        when the undo is unavailable, `can_undo_clock_out` is false,
        `undo_deadline` is null and `undo_seconds_remaining` is 0.
      required:
      - attendance_record_id
      - can_undo_clock_out
      - undo_deadline
      - undo_seconds_remaining
      properties:
        attendance_record_id:
          type: integer
          nullable: true
          description: The record to POST the undo to, or null when there is nothing
            to undo.
        can_undo_clock_out:
          type: boolean
          description: Whether the caller may undo this clock-out right now.
        undo_deadline:
          type: string
          format: date-time
          nullable: true
          description: ISO-8601 instant the window closes (clock-out time + 15 minutes).
        undo_seconds_remaining:
          type: integer
          description: |
            Whole seconds left, measured on the server clock. Render the
            countdown from this rather than from `undo_deadline` so a device
            with clock drift still counts down correctly.
    SitesPage:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
          description: The live title (a translated one when the detail serves a translation).
        slug:
          type: string
        description:
          type: string
          nullable: true
        page_type:
          type: string
        status:
          type: string
        position:
          type: integer
          nullable: true
        applies_to_everyone:
          type: boolean
          description: False when the page is restricted to part of the company.
        version_number:
          type: integer
          nullable: true
          description: For a reader, the newest PUBLISHED version (null before the
            first recorded publish); for an editor of the site, the page's version
            counter, which counts editor snapshots too.
        published_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        site:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            display_name:
              type: string
              description: The name in the caller's language; the original when there
                is no translation.
            slug:
              type: string
            site_type:
              type: string
        created_by:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        owner:
          type: object
          nullable: true
          description: Who answers for the page, while still an active member.
          properties:
            id:
              type: integer
            name:
              type: string
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
          description: When someone last marked the page Still accurate.
        ai_assisted:
          type: boolean
          description: 'Written with help from AI (Knowledge Layer E6), resolved for
            the caller: true only when the company shows that mark to readers, or
            the caller is the page''s author or an editor of its site. An AI edit
            to a published page''s unpublished draft marks the draft, so readers of
            the live page are told only once it is published.'
        is_published:
          type: boolean
        watching:
          type: boolean
          description: Whether the caller watches the page (`POST` / `DELETE /sites/pages/{page_id}/watch`).
        bookmarked:
          type: boolean
          description: Whether the caller bookmarked the page (`POST /sites/bookmarks`
            adds one).
        bookmark_id:
          type: integer
          nullable: true
          description: The caller's bookmark of the page — the id `DELETE /sites/bookmarks/{id}`
            removes; null when not bookmarked.
        comment_count:
          type: integer
          nullable: true
          description: Comments on the page, replies included; null while the company
            has comments switched off.
        workflow_status:
          "$ref": "#/components/schemas/SitesPageWorkflowStatus"
    SitesPageWorkflowStatus:
      type: object
      nullable: true
      description: 'The status badge a page wears for the people who look after it
        — the site''s editors (owners, editors, Sites admins) and the page''s assigned
        approver; null for everyone else, who see no workflow badge. Sent on every
        page row (`GET /sites/pages`, the `pages` of `GET /sites/sites/{id}`, and
        the caller''s own lists). The web page list draws one badge per fact; the
        phone draws one, in the order review state, Draft (`status`), Going stale,
        expiry. The approval itself is the page''s: open it with `GET /sites/pages/{id}`
        (`approval`) and decide it with `POST /sites/approvals/{page id}/approve |
        reject | request_changes`.'
      properties:
        review_status:
          type: string
          nullable: true
          enum:
          - pending_review
          - changes_requested
          - rejected
          - approved
          description: Where the page stands on approval, while that needs a person
            (`pending_review`, `changes_requested`, `rejected`) or it is `approved`
            with something still to publish. Null otherwise, and while the company
            has content approval switched off.
        review_status_label:
          type: string
          nullable: true
          description: The web's words for it — "Pending approval", "Changes requested",
            "Rejected", "Approved", or "Request withdrawn" / "Approval withdrawn"
            when an edit withdrew it.
          example: Pending approval
        stale:
          type: boolean
          description: Going stale — published and neither republished nor marked
            Still accurate within the company's review interval.
        expiry:
          type: object
          nullable: true
          description: The page's expiry date, when it has one — the same block as
            the page detail's `expiry`. "Expires in N days" counts to `expires_at`.
          properties:
            expires_at:
              type: string
              format: date-time
            expired:
              type: boolean
            expiring_soon:
              type: boolean
              description: Within the next 7 days.
    SitesPagePerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    SitesTrustLine:
      type: object
      description: The page's foot line — "Owned by <owner_name> · <label> <at>".
        The page detail's `trust_line`, and the answer to POST /sites/pages/{id}/mark_accurate.
      properties:
        owner_name:
          type: string
          nullable: true
          description: The page's owner or owning team while still active; null names
            no one.
        label:
          type: string
          enum:
          - Published
          - Checked
          - Last updated
          description: "`Checked` when someone marked it Still accurate since the
            last publish; `Last updated` on a draft or an editor's draft body."
        at:
          type: string
          format: date-time
    SitesPageDetail:
      type: object
      description: What the page's own screen shows around its body (`GET /sites/pages/{id}`
        only) — each band answered by the rule the web page asks. A band the caller
        is not shown is null.
      properties:
        trust_line:
          "$ref": "#/components/schemas/SitesTrustLine"
        audience_label:
          type: string
          nullable: true
          description: Who may open a restricted page ("Restricted to …"), shown to
            every reader while the company uses audiences; null when it is open to
            everyone.
        audience:
          type: object
          nullable: true
          description: The same audience as `audience_label`, as data, under the same
            rule (null when the page is open to everyone or the company does not use
            audiences) — for a client that composes its own line, e.g. "Visible to
            Operations · New York, Austin and San Francisco".
          properties:
            groups:
              type: array
              description: One entry per rule type, in the order department, location,
                organizational_role, job_family, job_title, group, platform_role,
                user. Empty when no rule names anyone who still exists — then only
                admins may open the page.
              items:
                type: object
                properties:
                  type:
                    type: string
                    enum:
                    - department
                    - location
                    - organizational_role
                    - job_family
                    - job_title
                    - group
                    - platform_role
                    - user
                  names:
                    type: array
                    items:
                      type: string
          example:
            groups:
            - type: department
              names:
              - Operations
            - type: location
              names:
              - New York
              - Austin
              - San Francisco
        expiry:
          type: object
          nullable: true
          description: The page's expiry date, when it has one.
          properties:
            expires_at:
              type: string
              format: date-time
            expired:
              type: boolean
            expiring_soon:
              type: boolean
              description: Within the next 7 days — when the web page header badges
                it.
        in_short:
          type: object
          nullable: true
          description: The "In short" summary, when the company and the page show
            one, it describes the published text and nothing on the page has its own
            audience. Never over an editor's draft body.
          properties:
            summary:
              type: string
            machine_written:
              type: boolean
              description: False for a translation's summary a person typed — then
                it is not captioned as written by AI.
            language:
              type: string
              nullable: true
              description: The translation's language code; null for the original.
        reactions:
          type: object
          description: The page's reaction bar.
          properties:
            total:
              type: integer
              description: Every reaction on the page.
            counts:
              type: array
              description: Most-reacted first.
              items:
                allOf:
                - "$ref": "#/components/schemas/SitesReaction"
                - type: object
                  properties:
                    count:
                      type: integer
            mine:
              type: array
              description: The reactions the caller left.
              items:
                type: string
            available:
              type: array
              description: What the caller may react with — the company's palette,
                plus any reaction the caller already holds.
              items:
                "$ref": "#/components/schemas/SitesReaction"
        version:
          type: object
          nullable: true
          description: The live version — the newest PUBLISHED one (never an editor's
            snapshot); null before the first recorded publish.
          properties:
            number:
              type: integer
            published_at:
              type: string
              format: date-time
            published_by:
              "$ref": "#/components/schemas/SitesPagePerson"
            change_note:
              type: string
              nullable: true
              description: The publisher's "What changed?" note.
            history_enabled:
              type: boolean
              description: Whether the company keeps version history (so "See what
                changed" can be offered).
        update_notice:
          type: object
          nullable: true
          description: '"Updated since you last read it on <last_read_at>: <note>"
            — only when the page was republished WITH a change note since the caller''s
            previous read. Asked before this read is recorded.'
          properties:
            last_read_at:
              type: string
              format: date-time
            note:
              type: string
            from_version:
              type: integer
              nullable: true
              description: The version live at the caller's previous read.
            to_version:
              type: integer
              nullable: true
              description: The version live now.
            comparable:
              type: boolean
              description: True when "See what changed" can compare the two (version
                history on
              two different versions).:
        approval:
          type: object
          nullable: true
          description: The page's Approval card and panel, for the people the approval
            is about — its editors, the approver, whoever asked and its author — while
            a request is in flight or decided, and to an editor on an unpublished
            page or beside unpublished changes. Null for everyone else, and with page
            approvals switched off. Names and notes go only to editors, the approver
            and whoever asked.
          properties:
            status:
              type: string
              enum:
              - none
              - pending_review
              - approved
              - rejected
              - changes_requested
            status_label:
              type: string
              nullable: true
              example: Pending approval
            summary:
              type: string
              nullable: true
              description: The card's one line
              e.g. "Waiting on your decision." or "Waiting for approval from Kevin Robinson.":
            waiting_on_you:
              type: boolean
              description: The caller decides it.
            approver:
              "$ref": "#/components/schemas/SitesPagePerson"
            requested_by:
              "$ref": "#/components/schemas/SitesPagePerson"
            decided_by:
              "$ref": "#/components/schemas/SitesPagePerson"
            requested_at:
              type: string
              format: date-time
              nullable: true
            reviewed_at:
              type: string
              format: date-time
              nullable: true
            waiting_long:
              type: boolean
              description: Waiting longer than the approvals queue flags.
            request_note:
              type: string
              nullable: true
              description: The note written when approval was asked for.
            decision_note:
              type: string
              nullable: true
              description: The approver's note on the decision.
            withdrawn_by_edit:
              type: boolean
              description: The author's own edit withdrew the request; nobody decided
                it.
            audience_changes:
              type: array
              description: Sections whose audience changes when this is published
                — what the approver cannot see in the body.
              items:
                type: object
                properties:
                  label:
                    type: string
                  audience:
                    type: string
            schedule_text:
              type: string
              nullable: true
              description: For the approver of a scheduled draft — what approving
                does to the schedule.
            revision:
              type: string
              nullable: true
              description: For the approver only — send it as `review_revision` to
                `POST /sites/approvals/{id}/approve`, `/reject` or `/request_changes`.
            last_published_version:
              type: integer
              nullable: true
              description: For the approver — the live version "What changed" compares
                the draft against.
            actions:
              type: array
              description: 'The decisions the caller may take through the Approvals
                API, in order: `approve`, `request_changes`, `reject` (the approver,
                while it waits). The web panel''s Withdraw, Publish and Request approval
                have no API door and are never listed.'
              items:
                type: string
                enum:
                - approve
                - request_changes
                - reject
        care:
          type: object
          nullable: true
          description: For whoever looks after the page — its owner and anyone who
            may edit it. Null for everyone else.
          properties:
            stale:
              type: object
              nullable: true
              description: Going stale — published, and neither republished nor marked
                Still accurate within the company's review interval.
              properties:
                since:
                  type: string
                  format: date-time
                  description: The last publish or Still accurate check.
                checked:
                  type: boolean
                  description: True when `since` is a Still accurate check rather
                    than a publish.
                review_interval_days:
                  type: integer
            can_mark_accurate:
              type: boolean
              description: The caller may mark the page Still accurate (its owner
                or an editor; published pages only) — POST /sites/pages/{id}/mark_accurate.
            source_refresh:
              type: object
              nullable: true
              description: Editors only — a source the page is built from changed.
              properties:
                state:
                  type: string
                  enum:
                  - import_update
                  - pending
                  - drift
                  - in_draft
                  description: "`import_update` — the web page it was imported from
                    changed and an update waits; `pending` — a refresh is staged for
                    review; `drift` — a bound source changed since it was published;
                    `in_draft` — the refresh is in the draft and only needs publishing."
                prunable:
                  type: boolean
                  description: A staged refresh drops linked records that no longer
                    exist.
        workflow:
          type: object
          nullable: true
          description: Editors only — the header's own badges.
          properties:
            unpublished_changes:
              type: boolean
            scheduled_publish_at:
              type: string
              format: date-time
              nullable: true
              description: A draft's scheduled publish.
        viewer:
          type: object
          properties:
            can_edit:
              type: boolean
            can_bookmark:
              type: boolean
              description: False on a site in the Trash.
            can_watch:
              type: boolean
              description: False while the company has follows switched off.
            can_delete:
              type: boolean
              description: Move to Trash (`DELETE /sites/pages/{id}`) — the site's
                owners and company admins / Sites app-admins, with a `destructive:sites`
                token; false while the page or its site is still being imported.
            can_archive:
              type: boolean
              description: Archive (`POST /sites/pages/{id}/archive`) — the site's
                owners and editors and company admins / Sites app-admins, with a `destructive:sites`
                token, on a page not already archived; false while the page or its
                site is still being imported.
        share_url:
          type: string
          nullable: true
          description: The stable page link (survives a rename or a move); published
            pages off the Trash only.
        can_share:
          type: boolean
          description: 'May the caller share this page (POST /shares): anyone who
            can open it, with a `write:shares` token. On the page detail (GET /sites/pages/{id})
            only.'
        web_view_url:
          type: string
          example: https://acme.workforce.mangoapps.com/m/apps/sites/298/pages/259?embed=1
          description: 'The page''s CONTENT alone, for the app''s WebView — its sections
            and nothing else: no site header, title, badges, reactions, actions, comments
            or Ask AI (draw those from this payload). The body is the one the web
            page shows the caller: the draft for an editor of the site and for the
            approver deciding a pending change, else the live body in the caller''s
            language, cut to the sections their audience may read. Carries `locale`
            when this request asked for one. Open it with the web session the app
            gets from `POST /auth/webview_token` → `/m/login/native?token=…&return_to=<this
            URL''s path and query>`. Opening it records no read; this request is what
            counts as one.'
    SitesDirectoryGroupRef:
      type: object
      description: 'The All sites group a site files under: its For field''s first
        axis (a place level, plural — `place-<depth>`; a department branch — `departments`
        or `department-<id>`; `formats` — Brands and formats), else its category (`category-<site_type>`),
        titled in the tenant''s words.'
      properties:
        key:
          type: string
          example: category-team
        title:
          type: string
          example: Team
    SitesDirectoryGroup:
      allOf:
      - "$ref": "#/components/schemas/SitesDirectoryGroupRef"
      - type: object
        properties:
          count:
            type: integer
            description: The sites in this group across the whole filtered set.
    SitesSite:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          description: The name as written (for editing). Draw `display_name`.
        slug:
          type: string
        description:
          type: string
          nullable: true
          description: The description as written (for editing). Draw `display_description`.
        display_name:
          type: string
          description: The name in the caller's language (Knowledge Layer P6); the
            original when there is no translation.
        display_description:
          type: string
          nullable: true
          description: The description in the caller's language; the original when
            there is no translation.
        site_type:
          type: string
        status:
          type: string
        icon:
          type: string
          nullable: true
        cover_image:
          type: string
          nullable: true
        icon_color:
          type: string
          nullable: true
          description: The icon's own colour on site cards and lists (a hex such as
            `#be123c`); null is Automatic — the site's colour, else its category's
            tone.
        cover_tone:
          type: string
          nullable: true
          enum:
          - light
          - dark
          description: How the cover reads under the site's name — `light` takes dark
            ink, `dark` light ink, each over a scrim of the opposite tone at `cover_scrim`
            alpha. Null while the cover is not measured; light ink over a 0.6 black
            scrim reads on any picture.
        cover_scrim:
          type: number
          nullable: true
          description: The alpha (0–1) of the scrim behind the name and description
            that clears WCAG AA on the measured cover; null with `cover_tone`.
        pages_count:
          type: integer
          description: 'The pages the site''s page list shows the caller (`GET /sites/pages?site_id=`,
            and `pages` on `GET /sites/sites/{id}`): every page to a Sites admin and
            to the site''s owners and editors; to anyone else the pages their audience
            admits that are published, their own, or sent to them for review — the
            number the desktop tile prints.'
        published_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: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        member_role:
          type: string
          nullable: true
          description: The caller's role in the site, if they are a member.
        is_published:
          type: boolean
        is_draft:
          type: boolean
          description: The site is a draft (`status` is `draft`) — only its members
            and admins can open it. The Draft chip; with `can_publish`, the draft
            site's "Publish site" button under Follow.
        following:
          type: boolean
          description: Whether the caller follows this site (`POST` / `DELETE /sites/sites/{site_id}/follow`).
        parent_site_id:
          type: integer
          nullable: true
          description: The site this one sits under (E3); null for a top-level site.
        allows_child_sites:
          type: boolean
          description: Whether sites may be placed under this one.
        for_criteria:
          type: object
          description: Who the site is For (the Audience::Dimensions criteria shape)
            — only the axes that name anyone; `{}` when it is For nobody in particular.
          properties:
            department_ids:
              type: array
              items:
                type: string
              description: Departments, and the departments under them.
            location_ids:
              type: array
              items:
                type: string
              description: Places, and the places under them.
            location_attribute_values:
              type: array
              items:
                type: string
              description: '"key=value" place attributes, e.g. "store_format=hub".'
          example:
            department_ids:
            - '12'
        group:
          "$ref": "#/components/schemas/SitesDirectoryGroupRef"
        chip:
          type: string
          nullable: true
          description: Why the site is the caller's, for the chip All sites puts on
            it — `Member`, `Following`, `Your department`, `Your <place level>` (in
            the tenant's words, e.g. `Your region`), `Your location`, or `For you`;
            null when it is not theirs (no chip).
          example: Member
        sites_inside:
          type: integer
          description: The sites beneath this one at any depth that the caller may
            open ("2 sites inside").
        last_updated_at:
          type: string
          format: date-time
          description: When a reader last saw the site change — the later of the site
            row's `updated_at` and its newest page change the caller can read (a page
            write never touches the site row).
        can_edit:
          type: boolean
          description: Whether the caller may edit the site — a Sites admin, or an
            owner or editor of it (by membership, or by a Sites grant that manages
            the places or departments the site is For).
        can_archive:
          type: boolean
          description: Archive (`POST /sites/sites/{id}/archive`) would not be refused
            — the site's owners and editors and company admins / Sites app-admins,
            with a `destructive:sites` token, on a site not already archived; false
            while the site is still being imported.
        can_trash:
          type: boolean
          description: Move to Trash (`POST /sites/sites/{id}/trash`) would not be
            refused — the site's owners and company admins / Sites app-admins, with
            a `destructive:sites` token; false while the site is still being imported.
        can_publish:
          type: boolean
          description: Publish (`POST /sites/sites/{id}/publish`) would not be refused
            — the site's owners and editors and company admins / Sites app-admins,
            with a `write:sites` token, on a site that is not already published, not
            in Trash, not still being imported and whose imported audience is not
            still waiting for a Sites admin. True on an archived site too (the web's
            Manage ▾ › Publish); the draft site's "Publish site" prompt is `is_draft
            && can_publish`.
        restricted:
          type: boolean
          description: True only to those who may edit the site (`can_edit`), while
            the tenant's audience restrictions are on, when the site is open to a
            limited audience — the Restricted chip. Always false for a reader.
    SitesSearchSite:
      description: A site the search found — a site row of the caller's own lists,
        plus where it sits.
      allOf:
      - description: A row that is a site (Following, a saved site) — SitesListSite
          plus what a site row draws.
        allOf:
        - "$ref": "#/components/schemas/SitesListSite"
        - type: object
          properties:
            display_description:
              type: string
              nullable: true
              description: The description in the caller's language; the original
                when there is no translation.
            restricted:
              type: boolean
              description: The site's audience is restricted — shown only to people
                who may edit the site (and Sites admins), while the tenant's audience
                restrictions are on; always false for everyone else (the All sites
                tile's Restricted chip).
      - type: object
        required:
        - pages_count
        - sites_inside
        - parents
        properties:
          pages_count:
            type: integer
            description: The pages the site's own list shows the caller (draw "3 pages").
          sites_inside:
            type: integer
            description: The sites beneath this one, at any depth, that the caller
              may open (draw "2 sites inside"; nothing when 0).
          parents:
            type: array
            description: The sites this one sits in, root first (at most two), naming
              only sites the caller may open; empty for a top-level site or when the
              caller may not open its parent. Draw it as "in Operations › Midwest".
            items:
              type: object
              properties:
                id:
                  type: integer
                name:
                  type: string
                display_name:
                  type: string
                  description: The name in the caller's language; the original when
                    there is no translation.
    SitesSearchResult:
      type: object
      required:
      - id
      - title
      - slug
      - page_type
      - updated_at
      - site
      properties:
        id:
          type: integer
        title:
          type: string
        slug:
          type: string
        page_type:
          type: string
        description:
          type: string
          nullable: true
        excerpt:
          type: string
          nullable: true
          description: The sentence around the first match in the body text the caller
            may read (or the description), with "…" where it was cut; for a page matched
            by its title alone, its description (up to 200 characters). Null when
            there is neither.
        matched_term:
          type: string
          nullable: true
          description: The words in `excerpt` that matched, exactly as the page writes
            them (to highlight); null for a page matched by its title alone.
        published_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
        site:
          description: The page's site as every list draws it — draw `display_name`
            under the title, and the site's `icon` on its `tile_color` as the row's
            icon.
          allOf:
          - description: A site as the caller's lists draw it — the icon on its tile
              colour.
            allOf:
            - "$ref": "#/components/schemas/SitesRef"
            - type: object
              properties:
                slug:
                  type: string
                site_type:
                  type: string
                status:
                  type: string
                  description: "`draft` draws the Draft badge (only the site's members
                    and Sites admins can open a draft site)."
                icon:
                  type: string
                  nullable: true
                  description: The site's Font Awesome icon class; null draws the
                    default globe.
                icon_color:
                  type: string
                  nullable: true
                  description: The site's own Icon colour (hex); null is Automatic.
                tile_color:
                  type: string
                  description: The hex the site's icon tile paints — its Icon colour,
                    else its own or inherited colour, else its category's tone. Never
                    null.
        created_by:
          type: object
          nullable: true
          description: The page's author; null when the author is not recorded.
          properties:
            id:
              type: integer
            name:
              type: string
    SitesBookmark:
      type: object
      required:
      - id
      - created_at
      - bookmarkable_type
      - page
      - site
      properties:
        id:
          type: integer
        created_at:
          type: string
          format: date-time
          description: When the caller bookmarked it.
        bookmarkable_type:
          type: string
          enum:
          - Site
          - SitePage
        page:
          nullable: true
          description: The bookmarked page; null for a bookmark of a site.
          allOf:
          - description: A page as every one of the caller's lists draws it.
            type: object
            properties:
              id:
                type: integer
              title:
                type: string
              display_title:
                type: string
                description: The title in the caller's language when a complete translation
                  exists, else the original.
              slug:
                type: string
              page_type:
                type: string
              status:
                type: string
                description: The page's status. Lists hold only pages the caller can
                  open, so a reader sees `published`; an editor or an assigned reviewer
                  can also see `draft` (draw the Draft badge).
              published_at:
                type: string
                format: date-time
                nullable: true
              site:
                "$ref": "#/components/schemas/SitesListSite"
              workflow_status:
                type: object
                nullable: true
                description: 'The status badge a page wears for the people who look
                  after it — the site''s editors (owners, editors, Sites admins) and
                  the page''s assigned approver; null for everyone else, who see no
                  workflow badge. Sent on every page row (`GET /sites/pages`, the
                  `pages` of `GET /sites/sites/{id}`, and the caller''s own lists).
                  The web page list draws one badge per fact; the phone draws one,
                  in the order review state, Draft (`status`), Going stale, expiry.
                  The approval itself is the page''s: open it with `GET /sites/pages/{id}`
                  (`approval`) and decide it with `POST /sites/approvals/{page id}/approve
                  | reject | request_changes`.'
                properties:
                  review_status:
                    type: string
                    nullable: true
                    enum:
                    - pending_review
                    - changes_requested
                    - rejected
                    - approved
                    description: Where the page stands on approval, while that needs
                      a person (`pending_review`, `changes_requested`, `rejected`)
                      or it is `approved` with something still to publish. Null otherwise,
                      and while the company has content approval switched off.
                  review_status_label:
                    type: string
                    nullable: true
                    description: The web's words for it — "Pending approval", "Changes
                      requested", "Rejected", "Approved", or "Request withdrawn" /
                      "Approval withdrawn" when an edit withdrew it.
                    example: Pending approval
                  stale:
                    type: boolean
                    description: Going stale — published and neither republished nor
                      marked Still accurate within the company's review interval.
                  expiry:
                    type: object
                    nullable: true
                    description: The page's expiry date, when it has one — the same
                      block as the page detail's `expiry`. "Expires in N days" counts
                      to `expires_at`.
                    properties:
                      expires_at:
                        type: string
                        format: date-time
                      expired:
                        type: boolean
                      expiring_soon:
                        type: boolean
                        description: Within the next 7 days.
        site:
          allOf:
          - description: A row that is a site (Following, a saved site) — SitesListSite
              plus what a site row draws.
            allOf:
            - "$ref": "#/components/schemas/SitesListSite"
            - type: object
              properties:
                display_description:
                  type: string
                  nullable: true
                  description: The description in the caller's language; the original
                    when there is no translation.
                restricted:
                  type: boolean
                  description: The site's audience is restricted — shown only to people
                    who may edit the site (and Sites admins), while the tenant's audience
                    restrictions are on; always false for everyone else (the All sites
                    tile's Restricted chip).
          description: The bookmarked site — a site row, with `display_description`
            and `restricted` — or, for a page bookmark, the page's site (the SitesListSite
            the page sits on, without those two). Draw `display_name` rather than
            `name`.
    SitesApprovalApproveRequest:
      type: object
      required:
      - review_revision
      properties:
        review_revision:
          type: string
          description: The row's `review.revision`, as the caller loaded it.
        notes:
          type: string
          nullable: true
          description: Optional — anything the person who asked should know.
    SitesApprovalNoteRequest:
      type: object
      required:
      - review_revision
      - notes
      properties:
        review_revision:
          type: string
          description: The row's `review.revision`, as the caller loaded it.
        notes:
          type: string
          description: Required — sent to the person who asked for approval. Leading
            and trailing spaces are dropped.
    SitesApprovalDecision:
      type: object
      properties:
        id:
          type: integer
          description: The page's id.
        decision:
          type: string
          enum:
          - approve
          - reject
          - request_changes
        message:
          type: string
          description: The web's confirmation — "Page approved.", "Page rejected."
            or "Changes requested."
        title:
          type: string
          description: The name that was decided (a drafted rename's).
        requested_by:
          nullable: true
          description: Who asked for approval — who is told about the decision.
          allOf:
          - "$ref": "#/components/schemas/SitesApprovalPerson"
        review:
          type: object
          properties:
            status:
              type: string
              enum:
              - approved
              - rejected
              - changes_requested
            status_label:
              type: string
              example: Changes requested
            reviewed_at:
              type: string
              format: date-time
            decided_by:
              "$ref": "#/components/schemas/SitesApprovalPerson"
            note:
              type: string
              nullable: true
              description: The note sent with the decision.
        waiting_count:
          type: integer
          description: The caller's Waiting on you count after the decision — the
            Approvals badge (`GET /apps` → `sites_approvals`).
        waiting_label:
          type: string
          description: What that count is made of, for a screen reader.
    SitesApprovalPerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
    SitesApprovalRow:
      type: object
      description: One page sent for approval, as the web Approvals row draws it.
      properties:
        id:
          type: integer
          description: The page's id.
        kind:
          type: string
          enum:
          - request
          - sent
          description: "`request` — someone's request (decide it, or see who has it);
            `sent` — the caller's own request, as Sent by me draws it (on Waiting
            on you both kinds appear)."
        title:
          type: string
          description: The name being approved — a published page's drafted rename,
            which goes live with the approved content.
        renames:
          type: string
          nullable: true
          description: The live title a drafted rename replaces ("renames “…”"); null
            without one.
        page:
          nullable: true
          description: The page and its site as every Sites list draws them; null
            only when the page's site is gone (the row offers no actions).
          allOf:
          - description: A page as every one of the caller's lists draws it.
            type: object
            properties:
              id:
                type: integer
              title:
                type: string
              display_title:
                type: string
                description: The title in the caller's language when a complete translation
                  exists, else the original.
              slug:
                type: string
              page_type:
                type: string
              status:
                type: string
                description: The page's status. Lists hold only pages the caller can
                  open, so a reader sees `published`; an editor or an assigned reviewer
                  can also see `draft` (draw the Draft badge).
              published_at:
                type: string
                format: date-time
                nullable: true
              site:
                "$ref": "#/components/schemas/SitesListSite"
              workflow_status:
                type: object
                nullable: true
                description: 'The status badge a page wears for the people who look
                  after it — the site''s editors (owners, editors, Sites admins) and
                  the page''s assigned approver; null for everyone else, who see no
                  workflow badge. Sent on every page row (`GET /sites/pages`, the
                  `pages` of `GET /sites/sites/{id}`, and the caller''s own lists).
                  The web page list draws one badge per fact; the phone draws one,
                  in the order review state, Draft (`status`), Going stale, expiry.
                  The approval itself is the page''s: open it with `GET /sites/pages/{id}`
                  (`approval`) and decide it with `POST /sites/approvals/{page id}/approve
                  | reject | request_changes`.'
                properties:
                  review_status:
                    type: string
                    nullable: true
                    enum:
                    - pending_review
                    - changes_requested
                    - rejected
                    - approved
                    description: Where the page stands on approval, while that needs
                      a person (`pending_review`, `changes_requested`, `rejected`)
                      or it is `approved` with something still to publish. Null otherwise,
                      and while the company has content approval switched off.
                  review_status_label:
                    type: string
                    nullable: true
                    description: The web's words for it — "Pending approval", "Changes
                      requested", "Rejected", "Approved", or "Request withdrawn" /
                      "Approval withdrawn" when an edit withdrew it.
                    example: Pending approval
                  stale:
                    type: boolean
                    description: Going stale — published and neither republished nor
                      marked Still accurate within the company's review interval.
                  expiry:
                    type: object
                    nullable: true
                    description: The page's expiry date, when it has one — the same
                      block as the page detail's `expiry`. "Expires in N days" counts
                      to `expires_at`.
                    properties:
                      expires_at:
                        type: string
                        format: date-time
                      expired:
                        type: boolean
                      expiring_soon:
                        type: boolean
                        description: Within the next 7 days.
        person:
          nullable: true
          description: Whose face leads the row — the requester on a request, the
            approver (who has it, or who decided it) on the caller's own. Null when
            no approver was chosen.
          allOf:
          - "$ref": "#/components/schemas/SitesApprovalPerson"
          - type: object
            properties:
              avatar_url:
                type: string
                nullable: true
                description: Absolute URL; null draws initials.
        requested_by:
          nullable: true
          description: Who asked for approval (on a request; null on the caller's
            own).
          allOf:
          - "$ref": "#/components/schemas/SitesApprovalPerson"
        approver:
          nullable: true
          description: Who the request was sent to; null when no approver was chosen.
          allOf:
          - "$ref": "#/components/schemas/SitesApprovalPerson"
        decided_by:
          nullable: true
          description: Who approved it or sent it back (on the caller's own, once
            decided).
          allOf:
          - "$ref": "#/components/schemas/SitesApprovalPerson"
        review:
          type: object
          properties:
            status:
              type: string
              enum:
              - pending_review
              - approved
              - changes_requested
              - rejected
            status_label:
              type: string
              nullable: true
              description: The state's words on a decided row of the caller's own
                — "Changes requested", "Rejected", "Request withdrawn", "Approved
                — ready to publish", "Approved — scheduled for …", "Approved — waiting
                for … to publish". Null while waiting.
            badge:
              type: string
              enum:
              - decided
              - waiting_long
              - asked
              description: '`decided` — draw `status_label` as a status badge; `waiting_long`
                — waiting past `wait_flag_after_days`, draw a Waiting badge from `requested_at`;
                `asked` — the wait as quiet text ("asked 2 hours ago").'
            wait_flag_after_days:
              type: integer
              example: 3
            requested_at:
              type: string
              format: date-time
              nullable: true
              description: When approval was asked for.
            reviewed_at:
              type: string
              format: date-time
              nullable: true
              description: When it was decided.
            withdrawn_by_edit:
              type: boolean
              description: The author's own edit voided the request; no approver decided
                it ("the page was edited after you sent it").
            approved_state:
              type: string
              nullable: true
              enum:
              - ready
              - scheduled
              - waiting
              description: 'An approved request not live yet: `ready` — the caller
                may publish it now; `scheduled` — its publish time will publish it;
                `waiting` — someone else publishes it (`status_label` says who).'
            note:
              type: string
              nullable: true
              description: The request note on a request; the approver's note on a
                decided row of the caller's own. Only for the approver, the requester
                and the site's editors — null for anyone else.
            scheduled_publish_at:
              type: string
              format: date-time
              nullable: true
            schedule_text:
              type: string
              nullable: true
              description: What the schedule means for this row, e.g. "This page is
                scheduled for Oct 21, 9:00 AM — approving lets it go live then."
            revision:
              type: string
              nullable: true
              description: The page's review revision, which a decision sends back
                so it is refused if the page changed; null when the caller cannot
                decide.
        waiting_for:
          type: string
          nullable: true
          description: When the caller can neither decide nor act on it, who the decision
            belongs to — "Waiting for Dana Whitfield" or "No approver chosen".
        actions:
          type: array
          description: 'The decisions the caller may take on this row through this
            API, in the order the web draws them: `request_changes`, `reject`, `approve`
            (POST /sites/approvals/{id}/request_changes, /reject, /approve) — only
            to whoever may decide it. The web row''s other actions (What changed,
            Publish, Withdraw, Edit page, Open page) have no API door and are never
            listed. Empty when there is nothing to decide.'
          items:
            type: string
            enum:
            - request_changes
            - reject
            - approve
        last_published_version:
          type: integer
          nullable: true
          description: The last published version, which the web's What changed diffs
            against the draft; null on a first approval.
    SitesCommentReactionWrite:
      allOf:
      - type: object
        properties:
          page_id:
            type: integer
          emoji:
            type: string
            description: The glyph acted on (a catalog key is answered as its glyph).
          reacted:
            type: boolean
            description: Whether the caller now holds this reaction.
          reactions:
            "$ref": "#/components/schemas/SitesReactionSummary"
      - type: object
        properties:
          comment_id:
            type: integer
            description: The comment or reply acted on.
          reactions:
            allOf:
            - type: object
              description: The page's reaction bar.
              properties:
                total:
                  type: integer
                  description: Every reaction on the page.
                counts:
                  type: array
                  description: Most-reacted first.
                  items:
                    allOf:
                    - "$ref": "#/components/schemas/SitesReaction"
                    - type: object
                      properties:
                        count:
                          type: integer
                mine:
                  type: array
                  description: The reactions the caller left.
                  items:
                    type: string
                available:
                  type: array
                  description: What the caller may react with — the company's palette,
                    plus any reaction the caller already holds.
                  items:
                    "$ref": "#/components/schemas/SitesReaction"
            description: The comment's reaction bar (not the page's).
    SitesDashboardDecisionCard:
      type: object
      description: One decision waiting on the caller, with its count and the first
        item.
      properties:
        key:
          type: string
          enum:
          - sites.reviews
          - sites.sent_back
          - sites.ready_to_publish
          - sites.scheduled
          description: "`sites.reviews` Pending approval (requests the caller decides)
            · `sites.sent_back` Sent back to you · `sites.ready_to_publish` Ready
            to publish · `sites.scheduled` Scheduled to publish."
        title:
          type: string
          example: Pending approval
        count:
          type: integer
        count_label:
          type: string
          nullable: true
          description: Drawn instead of `count` when present — "2 overdue" on the
            scheduled card.
        tone:
          type: string
          enum:
          - info
          - amber
          - success
          - danger
        icon:
          type: string
          example: fas fa-clipboard-check
        lead:
          type: string
          nullable: true
          description: The first page's title.
        lead_page_id:
          type: integer
          nullable: true
          description: The first page — open it with `GET /sites/pages/{id}`.
        lead_site_id:
          type: integer
          nullable: true
        sub:
          type: string
          nullable: true
          example: People & Culture · waiting 6 days
        cta:
          type: object
          description: The list that holds exactly the counted set.
          properties:
            label:
              type: string
              example: Open approvals
            list:
              type: string
              enum:
              - approvals
              - queue
            filter:
              type: string
              nullable: true
              description: 'With `list: approvals` — the Approvals filter (`mine`
                Assigned to me, `sent` Sent by me, `ready` Ready to publish); null
                is All pending (a Sites admin''s approvals card).'
            queue:
              type: string
              description: 'With `list: queue` — the page queue (`scheduled`).'
            web_path:
              type: string
              example: "/apps/sites/reviews?filter=mine"
    SitesDashboardCareRow:
      type: object
      properties:
        key:
          type: string
          enum:
          - owner
          - sources
          - links
          - stale
          - expiring
          - reading
        title:
          type: string
          example: Going stale
        count:
          type: integer
          description: Pages with this problem.
        count_text:
          type: string
          example: 2 pages
          description: '"3 links on 2 pages" for broken links.'
        detail:
          type: string
          nullable: true
        cta_label:
          type: string
          example: Review pages
        queue:
          type: string
          description: The page queue the row opens (`/apps/sites/queue?queue=<queue>`
            on the web).
    SitesDashboardSiteRow:
      type: object
      properties:
        site:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            display_name:
              type: string
              description: The name in the caller's language; the original when there
                is no translation.
            slug:
              type: string
            site_type:
              type: string
            status:
              type: string
              description: The web marks a site that is not published with its status.
            is_published:
              type: boolean
            icon:
              type: string
              nullable: true
            icon_color:
              type: string
              nullable: true
            cover_image:
              type: string
              nullable: true
        pages_count:
          type: integer
          description: Pages on the site
          every status. 0 is "No pages yet".:
        pages_needing_care:
          type: integer
          description: '"N of M pages" need care.'
        reads:
          type: integer
          nullable: true
          description: Page opens over `reads_days`. Null while analytics is off.
        reads_previous:
          type: integer
          nullable: true
        trend_percent:
          type: integer
          nullable: true
        last_published_at:
          type: string
          format: date-time
          nullable: true
          description: Null is "Not yet".
    SitesDashboardPage:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        slug:
          type: string
        site:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            display_name:
              type: string
    SitesRef:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        display_name:
          type: string
          description: The name in the caller's language; the original when there
            is no translation.
    SitesListSite:
      description: A site as the caller's lists draw it — the icon on its tile colour.
      allOf:
      - "$ref": "#/components/schemas/SitesRef"
      - type: object
        properties:
          slug:
            type: string
          site_type:
            type: string
          status:
            type: string
            description: "`draft` draws the Draft badge (only the site's members and
              Sites admins can open a draft site)."
          icon:
            type: string
            nullable: true
            description: The site's Font Awesome icon class; null draws the default
              globe.
          icon_color:
            type: string
            nullable: true
            description: The site's own Icon colour (hex); null is Automatic.
          tile_color:
            type: string
            description: The hex the site's icon tile paints — its Icon colour, else
              its own or inherited colour, else its category's tone. Never null.
    SitesListSiteRow:
      description: A row that is a site (Following, a saved site) — SitesListSite
        plus what a site row draws.
      allOf:
      - "$ref": "#/components/schemas/SitesListSite"
      - type: object
        properties:
          display_description:
            type: string
            nullable: true
            description: The description in the caller's language; the original when
              there is no translation.
          restricted:
            type: boolean
            description: The site's audience is restricted — shown only to people
              who may edit the site (and Sites admins), while the tenant's audience
              restrictions are on; always false for everyone else (the All sites tile's
              Restricted chip).
    SitesPageSummary:
      description: A page as every one of the caller's lists draws it.
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        display_title:
          type: string
          description: The title in the caller's language when a complete translation
            exists, else the original.
        slug:
          type: string
        page_type:
          type: string
        status:
          type: string
          description: The page's status. Lists hold only pages the caller can open,
            so a reader sees `published`; an editor or an assigned reviewer can also
            see `draft` (draw the Draft badge).
        published_at:
          type: string
          format: date-time
          nullable: true
        site:
          "$ref": "#/components/schemas/SitesListSite"
        workflow_status:
          type: object
          nullable: true
          description: 'The status badge a page wears for the people who look after
            it — the site''s editors (owners, editors, Sites admins) and the page''s
            assigned approver; null for everyone else, who see no workflow badge.
            Sent on every page row (`GET /sites/pages`, the `pages` of `GET /sites/sites/{id}`,
            and the caller''s own lists). The web page list draws one badge per fact;
            the phone draws one, in the order review state, Draft (`status`), Going
            stale, expiry. The approval itself is the page''s: open it with `GET /sites/pages/{id}`
            (`approval`) and decide it with `POST /sites/approvals/{page id}/approve
            | reject | request_changes`.'
          properties:
            review_status:
              type: string
              nullable: true
              enum:
              - pending_review
              - changes_requested
              - rejected
              - approved
              description: Where the page stands on approval, while that needs a person
                (`pending_review`, `changes_requested`, `rejected`) or it is `approved`
                with something still to publish. Null otherwise, and while the company
                has content approval switched off.
            review_status_label:
              type: string
              nullable: true
              description: The web's words for it — "Pending approval", "Changes requested",
                "Rejected", "Approved", or "Request withdrawn" / "Approval withdrawn"
                when an edit withdrew it.
              example: Pending approval
            stale:
              type: boolean
              description: Going stale — published and neither republished nor marked
                Still accurate within the company's review interval.
            expiry:
              type: object
              nullable: true
              description: The page's expiry date, when it has one — the same block
                as the page detail's `expiry`. "Expires in N days" counts to `expires_at`.
              properties:
                expires_at:
                  type: string
                  format: date-time
                expired:
                  type: boolean
                expiring_soon:
                  type: boolean
                  description: Within the next 7 days.
    SitesRecentPage:
      description: A page on Recent — SitesPageSummary plus what the Recent row draws.
      allOf:
      - "$ref": "#/components/schemas/SitesPageSummary"
      - type: object
        properties:
          updated_at:
            type: string
            format: date-time
            description: When a reader last saw the page change — its last publish
              when live, else its last edit.
          site:
            allOf:
            - "$ref": "#/components/schemas/SitesListSite"
            - type: object
              properties:
                parent:
                  nullable: true
                  description: The site this one sits in, named only when the caller
                    may open it; null for a top-level site or a parent the caller
                    cannot open.
                  allOf:
                  - "$ref": "#/components/schemas/SitesRef"
    SitesComment:
      type: object
      properties:
        id:
          type: integer
        body:
          type: string
          description: Plain text. Each `@[Name](mention:<id>)` mention reads "@Name",
            the name resolved from the id.
        body_html:
          type: string
          description: The body escaped as HTML, line breaks as `<br>`, each resolved
            mention a `span.mention-tag[data-user-id]`.
        parent_id:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        edited_at:
          type: string
          format: date-time
          nullable: true
        author:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        reaction_counts:
          type: object
          additionalProperties:
            type: integer
          example:
            "\U0001F44D": 2
        my_reactions:
          type: array
          items:
            type: string
        can_edit:
          type: boolean
          description: The caller may edit this comment (PATCH) — they wrote it.
        can_delete:
          type: boolean
          description: The caller may delete this comment (DELETE) — they wrote it,
            or they are a company admin / Sites app-admin or an owner or editor of
            the page's site.
        replies:
          type: array
          description: Top-level comments only (GET); oldest first.
          items:
            "$ref": "#/components/schemas/SitesComment"
    SitesPagination:
      type: object
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    SitesPersonRef:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
    SitesMember:
      type: object
      properties:
        id:
          type: integer
          description: The membership's id.
        role:
          type: string
          enum:
          - owner
          - editor
          - viewer
        role_label:
          type: string
          example: Editor
        user:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            avatar_url:
              type: string
              description: Absolute; an initials tile when the person has no photo.
        is_current_user:
          type: boolean
        can_remove:
          type: boolean
          description: The caller may remove this member — their own row too, to leave
            the site (never the site's last owner).
        added_by:
          allOf:
          - "$ref": "#/components/schemas/SitesPersonRef"
          nullable: true
          description: Who granted the membership; only for those who manage the site's
            members.
    SitesMemberSection:
      type: object
      properties:
        key:
          type: string
          enum:
          - owners
          - editors
          - readers
        title:
          type: string
          example: Owners
        role:
          type: string
          enum:
          - owner
          - editor
          - viewer
        count:
          type: integer
          description: Members of this role on the whole site.
        items:
          type: array
          description: This page's members of this role.
          items:
            "$ref": "#/components/schemas/SitesMember"
        audience:
          type: object
          description: "`readers` only — who can read the site without a membership."
          properties:
            restricted:
              type: boolean
            title:
              type: string
            text:
              type: string
            rules:
              type: array
              nullable: true
              items:
                type: string
              description: A restricted site's audience rules; only for those who
                manage the site's members.
    SitesMemberRoster:
      type: object
      properties:
        site:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            display_name:
              type: string
              description: The name in the caller's language.
        can_remove:
          type: boolean
          description: The caller may remove members of this site.
        sections:
          type: array
          items:
            "$ref": "#/components/schemas/SitesMemberSection"
        total_count:
          type: integer
        meta:
          type: object
          properties:
            total_count:
              type: integer
            total_pages:
              type: integer
            current_page:
              type: integer
            per_page:
              type: integer
            has_next_page:
              type: boolean
            has_prev_page:
              type: boolean
    SitesMemberRemoved:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation
          in the caller's language.:
        removed:
          type: object
          properties:
            user:
              "$ref": "#/components/schemas/SitesPersonRef"
            role:
              type: string
              enum:
              - owner
              - editor
              - viewer
        left_site:
          type: boolean
          description: The caller removed themselves.
        pages_handed_on:
          type: object
          nullable: true
          description: Null when the person owned no pages on the site.
          properties:
            count:
              type: integer
            new_owner:
              allOf:
              - "$ref": "#/components/schemas/SitesPersonRef"
              nullable: true
              description: Null when no active owner or editor was left to take them.
    SitesCareQueue:
      type: object
      properties:
        key:
          type: string
          enum:
          - care
          - owner
          - sources
          - links
          - stale
          - expiring
          - reading
        title:
          type: string
        cta_label:
          type: string
          description: The words on the list's fix (absent on `care`).
        count:
          type: integer
          description: On GET /sites/care_queues only — a list page's count is its
            `total_count`.
        detail:
          type: string
          description: The Dashboard row's one specific line
          when the list has pages.:
    SitesCarePageRef:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        site_id:
          type: integer
    SitesCareRow:
      type: object
      properties:
        page:
          description: A page as every one of the caller's lists draws it.
          type: object
          properties:
            id:
              type: integer
            title:
              type: string
            display_title:
              type: string
              description: The title in the caller's language when a complete translation
                exists, else the original.
            slug:
              type: string
            page_type:
              type: string
            status:
              type: string
              description: The page's status. Lists hold only pages the caller can
                open, so a reader sees `published`; an editor or an assigned reviewer
                can also see `draft` (draw the Draft badge).
            published_at:
              type: string
              format: date-time
              nullable: true
            site:
              "$ref": "#/components/schemas/SitesListSite"
            workflow_status:
              type: object
              nullable: true
              description: 'The status badge a page wears for the people who look
                after it — the site''s editors (owners, editors, Sites admins) and
                the page''s assigned approver; null for everyone else, who see no
                workflow badge. Sent on every page row (`GET /sites/pages`, the `pages`
                of `GET /sites/sites/{id}`, and the caller''s own lists). The web
                page list draws one badge per fact; the phone draws one, in the order
                review state, Draft (`status`), Going stale, expiry. The approval
                itself is the page''s: open it with `GET /sites/pages/{id}` (`approval`)
                and decide it with `POST /sites/approvals/{page id}/approve | reject
                | request_changes`.'
              properties:
                review_status:
                  type: string
                  nullable: true
                  enum:
                  - pending_review
                  - changes_requested
                  - rejected
                  - approved
                  description: Where the page stands on approval, while that needs
                    a person (`pending_review`, `changes_requested`, `rejected`) or
                    it is `approved` with something still to publish. Null otherwise,
                    and while the company has content approval switched off.
                review_status_label:
                  type: string
                  nullable: true
                  description: The web's words for it — "Pending approval", "Changes
                    requested", "Rejected", "Approved", or "Request withdrawn" / "Approval
                    withdrawn" when an edit withdrew it.
                  example: Pending approval
                stale:
                  type: boolean
                  description: Going stale — published and neither republished nor
                    marked Still accurate within the company's review interval.
                expiry:
                  type: object
                  nullable: true
                  description: The page's expiry date, when it has one — the same
                    block as the page detail's `expiry`. "Expires in N days" counts
                    to `expires_at`.
                  properties:
                    expires_at:
                      type: string
                      format: date-time
                    expired:
                      type: boolean
                    expiring_soon:
                      type: boolean
                      description: Within the next 7 days.
        site:
          description: A site as the caller's lists draw it — the icon on its tile
            colour.
          allOf:
          - "$ref": "#/components/schemas/SitesRef"
          - type: object
            properties:
              slug:
                type: string
              site_type:
                type: string
              status:
                type: string
                description: "`draft` draws the Draft badge (only the site's members
                  and Sites admins can open a draft site)."
              icon:
                type: string
                nullable: true
                description: The site's Font Awesome icon class; null draws the default
                  globe.
              icon_color:
                type: string
                nullable: true
                description: The site's own Icon colour (hex); null is Automatic.
              tile_color:
                type: string
                description: The hex the site's icon tile paints — its Icon colour,
                  else its own or inherited colour, else its category's tone. Never
                  null.
        kind:
          type: string
          description: The list's problem; on `care`, the page's first problem.
        kinds:
          type: array
          items:
            type: string
          description: On `care`, every problem the page has (the web's Needs column);
            else the list's key.
        detail:
          type: string
          nullable: true
          description: Why the page is here
          in one short line.:
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
          description: The last "Still accurate" check.
        freshness_at:
          type: string
          format: date-time
          nullable: true
          description: The later of the last publish and the last check — what Going
            stale measures.
        confirm_by:
          type: string
          format: date
          nullable: true
          description: A must-read page's confirm-by date.
        owner:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
            active:
              type: boolean
              description: False when the owner has left — the page waits on nobody.
        actions:
          type: object
          description: The fixes the caller may take on this row.
          properties:
            mark_accurate:
              type: boolean
              description: POST /sites/pages/{id}/mark_accurate
            extend_expiry:
              type: boolean
              description: POST /sites/pages/{page_id}/expiry_extension
            remind:
              type: boolean
              description: POST /sites/pages/{page_id}/read_confirmation_reminders
            review_refresh:
              type: boolean
              description: GET /sites/pages/{page_id}/source_refresh
    SitesSourceRefresh:
      type: object
      properties:
        page:
          allOf:
          - "$ref": "#/components/schemas/SitesCarePageRef"
          - type: object
            properties:
              status:
                type: string
        kind:
          type: string
          enum:
          - source
          - import_update
        staged:
          type: boolean
          description: A proposal is saved and ready to apply.
        has_content_change:
          type: boolean
        changes:
          type: array
          items:
            type: string
          description: What changed in the source page (import_update).
        drift:
          type: array
          description: The bound sources that changed (source).
          items:
            type: object
            properties:
              section_id:
                type: string
              section_type:
                type: string
              section_name:
                type: string
              heading:
                type: string
                nullable: true
              issue:
                type: string
                enum:
                - removed
                - source_updated
              source_label:
                type: string
              missing_records:
                type: integer
                description: Linked records no longer available
                removed from the section.:
        proposed_sections:
          type: array
          description: The page body after the refresh, section by section.
          items:
            type: object
            properties:
              position:
                type: integer
              type:
                type: string
              type_name:
                type: string
                nullable: true
              heading:
                type: string
                nullable: true
              excerpt:
                type: string
                nullable: true
              differs_from_draft:
                type: boolean
        proposal_digest:
          type: string
          description: A fingerprint of `proposed_sections` — send it back with apply.
        generated_at:
          type: string
          format: date-time
          nullable: true
        can_apply:
          type: boolean
        can_publish:
          type: boolean
        publish_refusal:
          type: string
          nullable: true
    SitesRemovedPage:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        site_id:
          type: integer
    SitesPageTrashed:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation, in the caller's language.
        page:
          "$ref": "#/components/schemas/SitesRemovedPage"
        trash:
          type: object
          properties:
            restorable_until:
              type: string
              format: date-time
              description: Until when the site's owners and editors can restore it
                (as a draft) from the site's Trash.
            restore_window_days:
              type: integer
              description: How long a page stays restorable in Trash.
            asked_to_confirm:
              type: boolean
              description: The page had versions that asked readers to confirm they
                had read it; those come back with it on restore, with the record of
                who confirmed.
    SitesPageArchived:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation, in the caller's language.
        page:
          allOf:
          - "$ref": "#/components/schemas/SitesRemovedPage"
          - type: object
            properties:
              status:
                type: string
                enum:
                - archived
    SitesReaction:
      type: object
      description: One reaction as the API names it.
      properties:
        emoji:
          type: string
          example: "\U0001F44D"
          description: The stored glyph.
        key:
          type: string
          nullable: true
          example: like
          description: The reaction's catalog key; null for a glyph outside the catalog.
        label:
          type: string
          example: Like
          description: The reaction's name — say it beside the icon.
        icon_url:
          type: string
          nullable: true
          description: The drawn icon the web shows (absolute); null when the reaction
            is drawn as its glyph.
    SitesReactionSummary:
      type: object
      description: The page's reaction bar.
      properties:
        total:
          type: integer
          description: Every reaction on the page.
        counts:
          type: array
          description: Most-reacted first.
          items:
            allOf:
            - "$ref": "#/components/schemas/SitesReaction"
            - type: object
              properties:
                count:
                  type: integer
        mine:
          type: array
          description: The reactions the caller left.
          items:
            type: string
        available:
          type: array
          description: What the caller may react with — the company's palette, plus
            any reaction the caller already holds.
          items:
            "$ref": "#/components/schemas/SitesReaction"
    SitesReactionRow:
      allOf:
      - "$ref": "#/components/schemas/SitesReaction"
      - type: object
        properties:
          id:
            type: integer
          reacted_at:
            type: string
            format: date-time
          user:
            type: object
            description: Who reacted. `name` is "Unknown" for someone who has since
              left the organization.
            properties:
              id:
                type: integer
              name:
                type: string
              initials:
                type: string
                nullable: true
              job_title:
                type: string
                nullable: true
              avatar_url:
                type: string
                nullable: true
                description: Absolute.
    SitesReactionWrite:
      type: object
      properties:
        page_id:
          type: integer
        emoji:
          type: string
          description: The glyph acted on (a catalog key is answered as its glyph).
        reacted:
          type: boolean
          description: Whether the caller now holds this reaction.
        reactions:
          "$ref": "#/components/schemas/SitesReactionSummary"
    SitesReadConfirmation:
      type: object
      description: 'Whether the live version of a page asks THIS caller to confirm
        reading it. `{ requested: false }` when it does not.'
      properties:
        requested:
          type: boolean
        acknowledged:
          type: boolean
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        version_number:
          type: integer
        due_on:
          type: string
          format: date
          nullable: true
        overdue:
          type: boolean
    SitesSitePublished:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation, in the caller's language.
        site:
          allOf:
          - type: object
            properties:
              id:
                type: integer
              name:
                type: string
                description: The name as written.
          - type: object
            properties:
              status:
                type: string
                enum:
                - published
              is_draft:
                type: boolean
                description: Always false once published.
              published_at:
                type: string
                format: date-time
    SitesRemovedSite:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          description: The name as written.
    SitesSiteArchived:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation, in the caller's language.
        site:
          allOf:
          - "$ref": "#/components/schemas/SitesRemovedSite"
          - type: object
            properties:
              status:
                type: string
                enum:
                - archived
              archived_at:
                type: string
                format: date-time
    SitesSiteTrashed:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation, in the caller's language.
        site:
          allOf:
          - "$ref": "#/components/schemas/SitesRemovedSite"
          - type: object
            properties:
              status:
                type: string
                description: The status the site had — Trash is not a status, and
                  a restored site comes back as a draft.
              trashed_at:
                type: string
                format: date-time
                description: When it was moved to Trash.
    SitesStillAccuratePerson:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
    SitesStillAccurate:
      type: object
      properties:
        message:
          type: string
          description: The web's confirmation, in the caller's language.
        page:
          type: object
          properties:
            id:
              type: integer
            title:
              type: string
            site_id:
              type: integer
        still_accurate:
          type: object
          properties:
            checked_at:
              type: string
              format: date-time
              description: The check — the page's new freshness date.
            checked_by:
              "$ref": "#/components/schemas/SitesStillAccuratePerson"
            review_interval_days:
              type: integer
              description: The company's review interval — how long the page stays
                off Going stale.
            next_check_due_at:
              type: string
              format: date-time
              description: When it goes stale again without another check or a republish.
            became_owner:
              type: boolean
              description: The page had no owner and the caller is now its owner.
            owner:
              nullable: true
              description: The page's owner after the check, while still an active
                member; null when it has none.
              allOf:
              - "$ref": "#/components/schemas/SitesStillAccuratePerson"
        trust_line:
          type: object
          description: The page's foot line — "Owned by <owner_name> · <label> <at>".
            The page detail's `trust_line`, and the answer to POST /sites/pages/{id}/mark_accurate.
          properties:
            owner_name:
              type: string
              nullable: true
              description: The page's owner or owning team while still active; null
                names no one.
            label:
              type: string
              enum:
              - Published
              - Checked
              - Last updated
              description: "`Checked` when someone marked it Still accurate since
                the last publish; `Last updated` on a draft or an editor's draft body."
            at:
              type: string
              format: date-time
    SocialPostEnvelope:
      type: object
      properties:
        post:
          type: object
          properties:
            urn:
              type: string
              example: urn:li:share:1234567890
            status:
              type: string
              enum:
              - pending
              - published
              - scheduled
              - failed
            author:
              type: string
              enum:
              - organization
              - member
            provider:
              type: string
              enum:
              - linkedin
            published_at:
              type: string
              format: date-time
              nullable: true
            error_code:
              type: string
              nullable: true
              description: |
                Internal error vocabulary — set when status=failed.
                One of: not_connected, auth_expired, invalid_payload,
                rate_limited, provider_error, unexpected.
            error_message:
              type: string
              nullable: true
            created_at:
              type: string
              format: date-time
    ErrorEnvelope:
      type: object
      properties:
        error:
          type: object
          required:
          - code
          - message
          properties:
            code:
              type: string
            message:
              type: string
            details:
              type: object
              additionalProperties: true
    SopHubSopSummary:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
          nullable: true
        status:
          type: string
          description: Open set; today draft, published, archived, retired.
        sop_type:
          type: string
          description: Open set.
        category:
          type: string
          nullable: true
        difficulty_level:
          type: string
          nullable: true
          description: Open set.
        step_count:
          type: integer
        estimated_minutes:
          type: integer
          nullable: true
        version_number:
          type: integer
        requires_acknowledgement:
          type: boolean
        requires_verification:
          type: boolean
        completion_validity_days:
          type: integer
          nullable: true
    SopHubPerson:
      type: object
      nullable: true
      properties:
        id:
          type: integer
        name:
          type: string
    SopHubCompletion:
      type: object
      properties:
        completion_id:
          type: integer
        status:
          type: string
          description: SopCompletion#status. Open set; today pending, in_progress,
            completed, pending_verification, re_completion_required, overdue.
        status_label:
          type: string
          description: The worker's word for the status — the web's own label, and
            "Sent back" for a completion a SOP Hub admin sent back. On the HR review
            list (and the verify / send-back answers) it is the web review list's
            label instead.
        completed_steps:
          type: integer
        total_steps:
          type: integer
        completion_percentage:
          type: number
        completion_method:
          type: string
          nullable: true
          description: Open set; server-set, never from the request.
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        acknowledged_at:
          type: string
          format: date-time
          nullable: true
        version_completed:
          type: integer
          nullable: true
        valid_until:
          type: string
          format: date-time
          nullable: true
        re_completion_deadline:
          type: string
          format: date-time
          nullable: true
        days_until_deadline:
          type: integer
          nullable: true
        verified_at:
          type: string
          format: date-time
          nullable: true
        verified_by:
          "$ref": "#/components/schemas/SopHubPerson"
        verification_notes:
          type: string
          nullable: true
          description: The signer's note, when the completion is signed off and they
            left one.
        re_completion_reason:
          type: string
          nullable: true
          description: 'Why a finished completion is open again — sent back, the procedure''s
            steps changed, or it expired — the sentence the web, /m/ and QR pages
            print. Null when it is not re-opened, and always null on the HR review
            list (and the verify / send-back answers): it is written to the worker.'
        sign_off_waiting_on:
          type: array
          items:
            "$ref": "#/components/schemas/SopHubPerson"
          description: Whom a completion awaiting sign-off waits on (the people its
            sign-off request was sent to; empty when none is on record). Only on a
            single completion — GET /sop_hub/sops/{id}, its start, step and submit,
            and GET /sop_hub/qr/{code} — never on a list's rows.
        sent_back:
          type: object
          nullable: true
          properties:
            by:
              "$ref": "#/components/schemas/SopHubPerson"
            reason:
              type: string
            at:
              type: string
              format: date-time
        steps_reset_at:
          type: string
          format: date-time
          nullable: true
          description: Set while the completion is open and its progress was cleared
            because the SOP's steps changed — the web's "This procedure was updated
            …, so your progress was reset" line. Null otherwise.
        sop:
          "$ref": "#/components/schemas/SopHubSopSummary"
        version_changed:
          "$ref": "#/components/schemas/SopHubVersionChanged"
        can_complete:
          type: boolean
          description: 'On the caller''s own lists (dashboard, my_pending, my_history):
            something is still owed, the SOP is published and its required training
            is done.'
        step_completions:
          type: array
          description: Present on a SOP's own completion and on every write.
          items:
            type: object
            properties:
              step_position:
                type: integer
              completed_at:
                type: string
                format: date-time
              notes:
                type: string
                nullable: true
    SopHubVersionChanged:
      type: object
      nullable: true
      description: 'On the caller''s own lists and a SOP''s own completion: set while
        the SOP''s version is past the one the caller completed — the web''s "Updated:
        version N to M · K steps changed" line. Null otherwise.'
      properties:
        from:
          type: integer
          description: The version the caller completed.
        to:
          type: integer
          description: The SOP's version now.
        steps_changed:
          type: integer
          nullable: true
          description: Steps added, changed, moved or removed since; null when the
            earlier version's steps are not on record.
    SopHubLibraryRow:
      allOf:
      - "$ref": "#/components/schemas/SopHubSopSummary"
      - type: object
        properties:
          updated_at:
            type: string
            format: date-time
          published_at:
            type: string
            format: date-time
            nullable: true
    SopHubFilterOption:
      type: object
      properties:
        key:
          type: string
          description: The value to send back as the filter.
        label:
          type: string
          description: The web's words, in the caller's language.
        count:
          type: integer
          description: SOPs this option would list, with the caller's other filters
            applied.
    SopHubStatusCounts:
      type: object
      description: The status chips. Counted with every filter applied except the
        status, so a chip's number is what choosing it lists.
      properties:
        total:
          type: integer
        published:
          type: integer
        draft:
          type: integer
        archived:
          type: integer
        retired:
          type: integer
    SopHubManual:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
          nullable: true
        status:
          type: string
        version:
          type: integer
        published_at:
          type: string
          format: date-time
          nullable: true
        sop_count:
          type: integer
    SopHubPageMeta:
      type: object
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        next_page:
          type: integer
          nullable: true
    SopHubReviewCompletion:
      allOf:
      - "$ref": "#/components/schemas/SopHubCompletion"
      - type: object
        properties:
          user:
            allOf:
            - "$ref": "#/components/schemas/SopHubPerson"
            - type: object
              properties:
                email:
                  type: string
                  nullable: true
                  description: The person's email, as the web list prints it under
                    their name — only where the People directory shows the caller
                    email; null otherwise.
          member:
            type: boolean
            description: The worker is still a member of the business.
          is_sample_data:
            type: boolean
          can_verify:
            type: boolean
            description: Awaiting sign-off, and not the caller's own completion.
          can_send_back:
            type: boolean
            description: Awaiting sign-off on a published SOP, not the caller's own,
              worker still a member.
          last_send_back:
            type: object
            nullable: true
            description: 'Set when this submission answers a send-back: who sent it
              back, why, and when. null for a first submission.'
            properties:
              by:
                "$ref": "#/components/schemas/SopHubPerson"
              reason:
                type: string
              at:
                type: string
                format: date-time
    SopHubVersion:
      type: object
      properties:
        version_number:
          type: integer
        label:
          type: string
        title:
          type: string
        change_summary:
          type: string
          nullable: true
          description: 'The change line in the caller''s language. Nobody types it:
            SOP Hub writes it when it snapshots a version ("Initial version", "Changed:
            steps and title", "Restored from v2 (changed: steps)"), and a version
            with none falls back to its change_reason''s words. `change` says the
            same as data.'
        change:
          type: object
          description: What the change line says, for a client that words it itself.
          properties:
            kind:
              type: string
              description: 'Open set; today initial · snapshot (recorded when a completion
                was filed against a version that had none) · fields_changed · restored
                · reason (no line stored: word `reason`) · text (other stored words,
                shown as `text`).'
            fields:
              type: array
              items:
                type: string
              description: 'For fields_changed and restored: the fields changed (steps,
                title, description, type, duration, roles, locations — an open set).'
            restored_from:
              type: integer
              nullable: true
              description: 'For restored: the version it was restored from.'
            reason:
              type: string
              nullable: true
              description: The version's change_reason (open set).
            text:
              type: string
              nullable: true
              description: 'For text: the stored words.'
        change_reason:
          type: string
          nullable: true
          description: Open set.
        was_published:
          type: boolean
        step_count:
          type: integer
        created_by:
          "$ref": "#/components/schemas/SopHubPerson"
        created_at:
          type: string
          format: date-time
    SopHubStep:
      type: object
      nullable: true
      properties:
        title:
          type: string
          nullable: true
        content:
          type: string
          nullable: true
        estimated_minutes:
          type: integer
          nullable: true
        verification_required:
          type: boolean
        media_url:
          type: string
          nullable: true
        safety:
          "$ref": "#/components/schemas/SopHubStepSafety"
    SopHubStepSafety:
      type: object
      nullable: true
      description: |
        The step's safety facts, drawn before its instructions: hazards, required PPE
        and tools, and the expected outcome ("Done when"). Null when the step carries
        none. On a translated reading they are the translated step's when it carries
        translated ones, else the original step's at the same position, in English;
        `language` says which.
      properties:
        hazards:
          type: array
          items:
            type: object
            properties:
              severity:
                type: string
                enum:
                - info
                - caution
                - warning
                - danger
              label:
                type: string
                description: The severity's word in the client's language (Note, Caution,
                  Warning, Danger).
              description:
                type: string
        ppe:
          type: array
          items:
            type: string
        tools:
          type: array
          items:
            type: string
        expected_outcome:
          type: string
          nullable: true
        language:
          type: string
          nullable: true
          description: The language code the facts read in on a translated reading
            ("en" when they were not translated); null on an untranslated one.
    SopHubReminderDispatch:
      type: object
      properties:
        queued:
          type: integer
          description: Reminders queued; they are sent in the background.
        throttled:
          type: integer
          description: 'Held back: reminded within the tenant''s frequency, or at
            the reminder limit.'
        tracked:
          type: boolean
          description: A Background Task tracks the delivery.
        message:
          type: string
    SopHubError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Open set.
            message:
              type: string
    SurveySummary:
      type: object
      description: A survey as it appears in a list. The serializer is an explicit
        allowlist — the record is never rendered wholesale, so no internal column
        and no respondent identity can leak into a response.
      required:
      - id
      - name
      - survey_type
      - status
      - is_open
      - is_anonymous
      - questions_count
      properties:
        id:
          type: integer
        name:
          type: string
        survey_type:
          type: string
          enum:
          - engagement
          - pulse
          - custom
          - manager_feedback
        status:
          type: string
          enum:
          - draft
          - active
          - closed
          - archived
        is_open:
          type: boolean
          description: 'True when the survey is `active` AND the current time is inside
            its `opens_at`/`closes_at` window. Not a restatement of `status` — an
            `active` survey outside its window reports `is_open: false`.'
        is_anonymous:
          type: boolean
          description: When true, responses are stored with identity hidden on every
            display surface, and results below the tenant's anonymity floor are suppressed
            entirely.
        description:
          type: string
          nullable: true
        opens_at:
          type: string
          format: date-time
          nullable: true
        closes_at:
          type: string
          format: date-time
          nullable: true
        questions_count:
          type: integer
          description: Caller-fillable input questions only; display blocks (instruction
            / section / header) are excluded. `0` for a survey with no form template.
            On a conversation survey, its fixed questions only.
        response_mode:
          type: string
          enum:
          - form
          - conversation
          description: 'How people answer: the form, or a conversation with the AI
            interviewer. A conversation survey is taken on its take page (`conversation.take_url`)
            and refuses `respond`.'
        conversation:
          type: object
          nullable: true
          description: Present only on a conversation survey.
          properties:
            minutes:
              type: integer
              description: How long the conversation takes, as its guide states it.
            take_url:
              type: string
              format: uri
              description: The web take page, which starts or resumes the caller's
                conversation.
            available:
              type: boolean
              description: False while new conversations can't start in the organization
                (conversation mode off, or its AI usage blocked), or — on a survey
                that names its respondents — once the caller's own monthly AI allowance
                is spent.
            reason:
              type: string
              nullable: true
              description: Why the conversation isn't available, in the words the
                Take list uses; null when it is.
        response_count:
          type: integer
          nullable: true
          description: PRESENT ONLY for a caller who may see it, and it means different
            things by tier. In the `scope=created` list, and on the detail payload
            at the `:all` / `:aggregate` results tiers, it is the whole-survey count
            of submitted-or-approved responses. On the detail payload at the `:team`
            tier it counts the caller's own direct reports only, and is `null` when
            an anonymous survey's team count is below the tenant's anonymity floor.
            Absent entirely from the employee self-service list and for a caller at
            the `:none` tier.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    SurveyDetail:
      allOf:
      - "$ref": "#/components/schemas/SurveySummary"
      - type: object
        description: The single-survey payload — everything above plus the questions.
        required:
        - completed
        - questions
        properties:
          completed:
            type: boolean
            description: Whether this caller has already submitted a response. A question
              of FACT, not of policy — a survey whose template allows multiple submissions
              still accepts another response while this is true.
          questions:
            type: array
            description: The caller-fillable questions, in builder order.
            items:
              "$ref": "#/components/schemas/SurveyQuestion"
          target_audience_count:
            type: integer
            description: Whole-survey audience size. Present only at the `:all` /
              `:aggregate` results tiers — never for a `:team` manager, and never
              for a caller at the `:none` tier.
          completion_rate:
            type: number
            format: float
            description: Whole-survey completion as a PERCENTAGE (0–100, one decimal
              place), not a fraction. Same tier rule as `target_audience_count`.
            example: 42.9
    SurveyQuestion:
      type: object
      description: One caller-fillable question.
      required:
      - id
      - field_name
      - label
      - field_type
      - required
      properties:
        id:
          type: integer
        field_name:
          type: string
          description: The key to use for this question inside `submission_data`.
        label:
          type: string
        field_type:
          type: string
          description: The underlying form field type — e.g. `text`, `textarea`, `number`,
            `email`, `select`, `multiselect`, `checkbox`, `radio`, `date`, `rating`,
            `slider`, `scale`, `matrix`, `rich_text`, `signature`, `file`, `image`.
            Determines the answer shape accepted by `respond` and whether the question
            is rolled up in `results`.
        required:
          type: boolean
        help_text:
          type: string
          nullable: true
          description: Null when the question carries no help text.
        options:
          type: array
          description: The selectable option values, for choice-style questions. An
            empty array for every other field type. Option objects configured with
            separate value/label pairs are flattened to their value.
          items:
            type: string
    SurveySubmission:
      type: object
      description: The receipt returned when a response is accepted.
      required:
      - id
      - survey_id
      - status
      - is_anonymous
      properties:
        id:
          type: integer
        survey_id:
          type: integer
        status:
          type: string
          description: Always `submitted` on this path — this endpoint writes that
            status explicitly and nothing downstream reassigns it.
          example: submitted
        is_anonymous:
          type: boolean
          description: The SURVEY's anonymity setting, echoed back on the receipt.
        submitted_at:
          type: string
          format: date-time
        message:
          type: string
          description: A ready-to-display confirmation naming the survey.
          example: Thank you! Your response to 'Q3 Engagement Pulse' has been submitted.
    SurveyResults:
      type: object
      description: Aggregate results for the caller's tier. Raw per-respondent rows
        and respondent identity never appear here.
      required:
      - survey
      - scope
      - team_view_available
      - minimum_response_threshold
      - threshold_met
      - question_summaries
      properties:
        survey:
          "$ref": "#/components/schemas/SurveyResultsSurvey"
        scope:
          type: string
          enum:
          - full
          - team
          description: "`full` for the `:all` and `:aggregate` tiers (org-wide numbers);
            `team` for a manager reading a slice of their own team."
        team_view_available:
          type: boolean
          description: "`true` when the caller reads this survey's whole-survey aggregate
            AND may read their team's slice of it too, with `scope=team` — offer both
            views. `false` where there is only one view: full results, an employee's
            aggregate, or a manager's team slice on a survey that does not share its
            results with employees."
        team_reach:
          type: object
          description: 'PRESENT ONLY when `scope` is `team`: whose answers the slice
            holds. `requested` is the `reach` asked for (`direct` when omitted or
            not one of the two values); `shown` is the slice actually rolled up. They
            differ, with `narrowed_to_direct: true`, when `reach=all` fell back to
            the direct reports because too few people below them answered — the web
            and phone pages say "Showing results from your direct reports only" in
            that case, and a client should too. They also differ, with `narrowed_to_direct:
            false` and `shown: all`, for a caller with no direct reports left who
            still leads people below them: the direct slice would be nobody, so the
            slice is everyone below — say "everyone below you", as the pages do.'
          required:
          - requested
          - shown
          - narrowed_to_direct
          properties:
            requested:
              type: string
              enum:
              - direct
              - all
            shown:
              type: string
              enum:
              - direct
              - all
            narrowed_to_direct:
              type: boolean
        minimum_response_threshold:
          type: integer
          minimum: 5
          description: The tenant's anonymity floor, after the platform minimum of
            5 is applied. A tenant may raise this but not lower it.
        threshold_met:
          type: boolean
          description: 'Whether the response count for this scope is positive AND
            at or above the threshold. `false` in the suppressed branch, except for
            a team slice withheld beside the whole survey (`anonymity_protected: true`),
            whose own count did meet it.'
        anonymity_protected:
          type: boolean
          description: PRESENT ONLY when suppression fired — an anonymous survey whose
            response count for this scope is below the threshold, or a team slice
            that fewer people than the threshold answered outside of, on an anonymous
            survey that shares its results with employees. When present it is `true`,
            `question_summaries` is empty, and no aggregate of any kind is returned.
            Absent (not `false`) on an unsuppressed response.
          example: true
        question_summaries:
          type: array
          description: One entry per answerable question, in builder order. `rating`,
            `number`, `slider`, `scale` are rolled up as `numeric`; `select`, `radio`,
            `checkbox`, `multiselect` as `choice`; `text`, `textarea`, `rich_text`
            as `text`; every other answerable type (date, time, email, phone, url,
            file, signature …) as `answered`, a count only. Fields nobody answers
            (calculated, hidden, lookup …) are omitted. Empty in the suppressed branch.
          items:
            "$ref": "#/components/schemas/SurveyQuestionSummary"
        shared_summary:
          type: object
          description: The "You said, we did" summary the survey shared with its audience
            — the organization's own words, so no anonymity floor applies. Present
            for a full-results reader, or for a caller it was sent to; omitted otherwise,
            and when the survey shared none.
          required:
          - text
          - shared_on
          properties:
            text:
              type: string
              description: The summary as it was sent.
            shared_on:
              type: string
              format: date
              nullable: true
              description: The day it was shared, in the caller's time zone.
    SurveySharedSummary:
      type: object
      description: A "You said, we did" summary shared with the caller — the organization's
        own words, so no anonymity floor applies.
      required:
      - survey_id
      - survey
      - shared_on
      - text
      properties:
        survey_id:
          type: integer
          description: The survey that shared it.
        survey:
          type: string
          description: The survey's title.
          example: Rota pulse
        shared_on:
          type: string
          format: date
          nullable: true
          description: The day it was shared, in the caller's time zone.
          example: '2026-09-15'
        text:
          type: string
          description: The summary as it was sent, plain text.
          example: You told us the rota came late. It now comes two weeks ahead.
    SurveyResultsSurvey:
      type: object
      description: The survey header block inside a results payload.
      required:
      - id
      - name
      - is_anonymous
      - status
      properties:
        id:
          type: integer
        name:
          type: string
        is_anonymous:
          type: boolean
        status:
          type: string
          enum:
          - draft
          - active
          - closed
          - archived
        response_count:
          type: integer
          nullable: true
          description: Responses counted for this scope — org-wide for `full`, the
            caller's team (direct reports, or everyone below them with `reach=all`)
            for `team`. `null` on an anonymous survey below the anonymity floor when
            the caller reads the `team` slice or an invitee's shared summary (the
            `:aggregate` tier), and for a `team` slice that fewer people than the
            floor answered outside of on an anonymous survey that shares its results
            with employees — the web masks the same count.
        target_audience_count:
          type: integer
          description: "`full` scope only — a team slice has no audience denominator."
        completion_rate:
          type: number
          format: float
          description: "`full` scope only. A PERCENTAGE (0–100, one decimal place),
            computed from the same `response_count` above; `0.0` when the audience
            is empty; `null` when `response_count` is masked."
    SurveyQuestionSummary:
      type: object
      description: One question's roll-up. `type` selects which of the remaining fields
        are present, and any of the four can instead carry `insufficient_data`.
      required:
      - field_name
      - label
      - type
      - count
      properties:
        field_name:
          type: string
        label:
          type: string
        type:
          type: string
          enum:
          - numeric
          - choice
          - text
          - answered
        count:
          type: integer
          description: For `numeric`, the number of answers that parsed as a number.
            For `choice`, the number of respondents who chose at least one option.
            For `text` and `answered`, the number of non-blank answers.
        exportable:
          type: boolean
          description: "`answered` only. `true` for date, datetime, time, email, phone,
            url, barcode, QR code and colour questions, whose answers the CSV export
            shows as written."
        insufficient_data:
          type: boolean
          description: 'Present only when `count` is below `minimum_response_threshold`:
            on `numeric` and `choice` summaries always, on `text` and `answered` summaries
            only for an anonymous survey. When present, `average`/`min`/`max` and
            `distribution` are all absent. A `text` summary never carries the answers,
            at any volume.'
          example: true
        average:
          type: number
          format: float
          description: "`numeric` only, rounded to 2 decimal places."
        min:
          type: number
          format: float
          nullable: true
          description: '`numeric` only. On an ANONYMOUS survey `null` when the lowest
            answer''s score is counted under "Other answers" (see `distribution`),
            and always `null` when the scale is drawn in ranged bars (keys like `"90–99"`).'
        max:
          type: number
          format: float
          nullable: true
          description: '`numeric` only. On an ANONYMOUS survey `null` when the highest
            answer''s score is counted under "Other answers" (see `distribution`),
            and always `null` when the scale is drawn in ranged bars (keys like `"90–99"`).'
        distribution:
          type: object
          description: 'For `numeric`, the answers bucketed by rounded value over
            the question''s scale (keys are the scale points, values are counts; a
            scale wider than eleven points is drawn in labelled bars of equal width,
            keyed like `"90–99"`). For `choice`, keyed by the stored answer value.
            `percentage` is that option''s share of TOTAL SELECTIONS, not of respondents,
            so on a multi-select question the percentages are over the number of boxes
            ticked rather than the number of people; `0.0` when nothing was selected.
            On an ANONYMOUS survey an answer is a group of people: a score or option
            fewer people than `minimum_response_threshold` chose is folded into one
            `"Other answers"` key, counted as the distinct people who chose any folded
            answer (the same rule a conversation survey''s findings apply), and an
            empty score is not listed — the scores missing beside it would be the
            folded few.'
          additionalProperties:
            oneOf:
            - type: integer
              description: "`numeric` — the number of answers at this scale point."
            - type: object
              description: "`choice` — this option's count and share."
              properties:
                count:
                  type: integer
                percentage:
                  type: number
                  format: float
    SurveyAnonymousFeedback:
      type: object
      description: A recorded anonymous feedback item. No submitter identity is stored
        on it.
      required:
      - id
      - topic
      - topic_label
      - message
      - status
      properties:
        id:
          type: integer
        topic:
          type: string
          enum:
          - workload
          - safety
          - leadership
          - culture
          - compensation
          - communication
          - other
        topic_label:
          type: string
          description: The display label for `topic`, from the app's single label
            taxonomy (e.g. `workload` renders as "Workload & Work-Life Balance").
            An unmapped topic falls back to a titleized form.
          example: Workload & Work-Life Balance
        message:
          type: string
        status:
          type: string
          enum:
          - new
          - in_progress
          - closed
          description: Always `new` on creation.
        claim_code:
          type: string
          nullable: true
          description: The one-way code the submitter keeps in order to look up this
            item's status later. Never tied to identity. 8 characters from an unambiguous
            uppercase alphabet (0/O/1/I/L excluded so a hand-copied code cannot be
            misread), unique per business.
          example: K7QM3XPB
        created_at:
          type: string
          format: date-time
    SurveysPaginationMeta:
      type: object
      description: Pagination metadata, computed off the live relation rather than
        off the returned page. The same four values are also returned as the `X-Total-Count`,
        `X-Total-Pages`, `X-Current-Page` and `X-Per-Page` response headers.
      required:
      - total_count
      - total_pages
      - current_page
      - per_page
      properties:
        total_count:
          type: integer
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    TrainingLessonHelpTurn:
      type: object
      properties:
        success:
          type: boolean
        conversation_id:
          type: string
          description: The private thread this turn streams on.
        status:
          type: string
          enum:
          - processing
        websocket:
          type: object
          properties:
            channel:
              type: string
              enum:
              - AiResponseChannel
            subscription:
              type: object
              properties:
                conversation_id:
                  type: string
        kind:
          type: string
          enum:
          - explain
          - example
          - recap
          - review
        sources:
          type: array
          description: The lessons the answer may cite, in citation order.
          items:
            type: object
            properties:
              label:
                type: string
                description: The citation's label in the reader's language ("Lesson
                  source", "Lesson source 2").
              course_id:
                type: integer
              lesson_id:
                type: integer
              lesson_title:
                type: string
              digest:
                type: string
                description: Pass to GET …/help/source to check the citation is still
                  current.
              anchor:
                type: string
                enum:
                - native-lesson-source
    TrainingQuizMissedReview:
      type: object
      nullable: true
      description: What you missed — the lessons behind the questions a failed attempt
        got wrong (the web's "What you missed"). Null when the attempt passed, or
        no missed question links a lesson in the learner's course version.
      properties:
        lessons:
          type: array
          description: Up to five, in course order; each opens the lesson.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 412
              title:
                type: string
                example: Cross-contact
              missed:
                type: integer
                description: How many missed questions this lesson covers.
                example: 2
        required:
          type: boolean
          description: The quiz asks for this review before a retake ("I've reviewed
            these lessons", POST /training/quiz_attempts/{id}/review_complete).
          example: true
        completed:
          type: boolean
          description: The learner already said they reviewed it.
          example: false
        recap_available:
          type: boolean
          description: 'Offer "Recap these lessons" (POST …/quiz_attempts/{attempt_id}/review_recap).
            True exactly when the web shows that button to this person: Ask AI is
            open to them, the Training assistant is on for the organization and for
            them, and a linked lesson they may open has text or an approved transcript.
            When false the recap call is refused.'
          example: true
    TrainingReturnReminderResponse:
      type: object
      properties:
        reminders:
          type: object
          properties:
            delivery_hour:
              type: integer
              nullable: true
              enum:
              - 9
              - 12
              - 17
              -
            enabled:
              type: boolean
              description: Whether the personal training_return notification category
                is enabled. Delivery also requires a non-null delivery_hour.
            timezone:
              type: string
              description: Learner's effective timezone name.
            daily_limit:
              type: integer
              enum:
              - 1
            weekly_limit:
              type: integer
              enum:
              - 2
            preference_id:
              type: integer
              nullable: true
            enrollment_id:
              type: integer
              nullable: true
            snoozed_until:
              type: string
              format: date-time
              nullable: true
            can_snooze:
              type: boolean
              description: True only for an eligible started course attempt and a
                caller with write:training.
        can_change:
          type: boolean
          description: Whether this caller has write:training.
        unread_notification_count:
          type: integer
    TrainingPracticePersona:
      type: object
      nullable: true
      description: The persona the learner talks to — on the card and the attempt,
        the one who opens the conversation. Practice personas carry no image, so `avatar_url`
        is always null today; draw initials.
      properties:
        id:
          type: integer
          example: 214
        name:
          type: string
          example: Dana
        role:
          type: string
          nullable: true
          example: Forklift operator
        avatar_url:
          type: string
          nullable: true
          example:
    TrainingPracticeCard:
      type: object
      properties:
        lesson_id:
          type: integer
          example: 1349
        title:
          type: string
          example: Rehearse the pre-shift toolbox talk
        description:
          type: string
          nullable: true
          example: Lead a 3-minute safety talk after a forklift near-miss.
        available:
          type: boolean
          example: true
        unavailable_reason:
          type: string
          nullable: true
          enum:
          - no_scenario
          - unpublished
          - app_disabled
          - no_persona
          -
          example:
        scenario:
          type: object
          nullable: true
          description: Null only when the lesson's scenario no longer exists (`no_scenario`).
          properties:
            id:
              type: integer
              example: 88
            title:
              type: string
              example: Rehearse the pre-shift toolbox talk
            difficulty:
              type: string
              enum:
              - beginner
              - intermediate
              - advanced
              example: beginner
            difficulty_label:
              type: string
              example: Beginner
            pass_threshold:
              type: integer
              description: Score out of 100 needed to pass.
              example: 70
            estimated_minutes:
              type: integer
              nullable: true
              example: 6
            persona:
              "$ref": "#/components/schemas/TrainingPracticePersona"
        enrolled:
          type: boolean
          example: true
        completed:
          type: boolean
          description: The lesson is complete on the current enrollment.
          example: false
        best_score:
          type: integer
          nullable: true
          description: Best final score (a manager override wins) of this lesson's
            scored attempts.
          example:
        attempts_count:
          type: integer
          description: This lesson's attempts on the current enrollment
          discarded ones excluded.:
          example: 0
        open_attempt_id:
          type: integer
          nullable: true
          description: An in-progress attempt that starting will resume.
          example:
        last_attempt:
          type: object
          nullable: true
          properties:
            id:
              type: integer
              example: 5012
            status:
              type: string
              enum:
              - in_progress
              - completed
              - scored
              example: scored
            score:
              type: integer
              nullable: true
              example: 82
            passed:
              type: boolean
              nullable: true
              description: Null until scored.
              example: true
            completed_at:
              type: string
              format: date-time
              nullable: true
        can_start:
          type: boolean
          description: Enrolled and available — draw Start (or Resume
          with open_attempt_id) only when true.:
          example: true
    TrainingPracticeTurn:
      type: object
      properties:
        id:
          type: integer
          example: 1
        role:
          type: string
          enum:
          - persona
          - learner
          example: persona
        content:
          type: string
          example: Morning. Is this about yesterday?
        speaker:
          type: string
          nullable: true
          description: The persona's name on a persona turn (a panel scenario has
            several); null on a learner turn.
          example: Dana
        created_at:
          type: string
          format: date-time
    TrainingPracticeScorecard:
      type: object
      nullable: true
      description: Present once `status` is `scored`.
      properties:
        score:
          type: integer
          description: 'Final score out of 100: a manager''s override when there is
            one, else the AI''s.'
          example: 82
        ai_score:
          type: integer
          nullable: true
          example: 82
        passed:
          type: boolean
          description: The stored outcome — never re-derive it from today's pass threshold.
          example: true
        narrative:
          type: string
          nullable: true
          example: You opened with the near-miss and kept it short.
        strengths:
          type: array
          items:
            type: string
          example:
          - Clear opening.
        improvements:
          type: array
          items:
            type: string
          example:
          - Ask the crew a question.
        criteria:
          type: array
          description: Every rubric criterion in authored order.
          items:
            type: object
            properties:
              id:
                type: integer
                example: 31
              name:
                type: string
                example: Names the hazard
              score:
                type: integer
                nullable: true
                description: 0–100, or null when the judge did not score it (a real
                  0 means the behaviour was absent).
                example: 80
              max:
                type: integer
                example: 100
              comment:
                type: string
                nullable: true
                example: Named the blind corner.
        reviewed:
          type: boolean
          description: A manager has reviewed the score.
          example: false
        review_note:
          type: string
          nullable: true
    TrainingPracticeAttempt:
      type: object
      properties:
        id:
          type: integer
          example: 5012
        lesson_id:
          type: integer
          example: 1349
        course_id:
          type: integer
          example: 773
        status:
          type: string
          enum:
          - in_progress
          - completed
          - scored
          - abandoned
          example: in_progress
        processing_status:
          type: string
          nullable: true
          enum:
          - queued
          - scoring
          - failed
          -
          description: Poll while `queued` or `scoring`; `failed` → re-POST complete
            to retry.
          example:
        scoring_stalled:
          type: boolean
          description: Scoring claims to be queued/scoring but has not moved for 20
            minutes — stop polling; POST complete re-queues it.
          example: false
        processing_error:
          type: string
          nullable: true
          description: When `processing_status` is `failed`, one fixed sentence written
            for the learner (the one the web pages show) — never the pipeline's own
            error text. Null otherwise.
          example:
        ai_notice:
          type: string
          description: One fixed sentence, in the reader's language, saying the partner
            is an AI. Always present; a client draws it on the conversation.
          example: You're practicing with an AI, not a real person.
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        pass_threshold:
          type: integer
          example: 70
        persona:
          "$ref": "#/components/schemas/TrainingPracticePersona"
        turns:
          type: array
          items:
            "$ref": "#/components/schemas/TrainingPracticeTurn"
        turns_total:
          type: integer
          example: 2
        turns_truncated:
          type: boolean
          description: True when more than the 120 turns returned exist.
          example: false
        can_reply:
          type: boolean
          description: The run is in progress and Practice Hub is on for the business.
          example: true
        can_complete:
          type: boolean
          description: Practice Hub is on, the learner has replied, and the run is
            in progress, ended unscored (a retry), or its scoring stalled.
          example: false
        can_discard:
          type: boolean
          example: true
        scorecard:
          "$ref": "#/components/schemas/TrainingPracticeScorecard"
        lesson_completed:
          type: boolean
          example: false
        course_status:
          type: string
          nullable: true
          example: in_progress
        course_progress_percentage:
          type: integer
          nullable: true
          example: 50
        unread_notification_count:
          "$ref": "#/components/schemas/UnreadNotificationCount"
        _meta:
          "$ref": "#/components/schemas/ResponseMeta"
    ManagedCourse:
      type: object
      description: A course published from outside the app, as Training holds it.
      required:
      - key
      - course
      - versions
      - lessons
      properties:
        key:
          type: string
          example: app-training:sites
        course:
          type: object
          required:
          - id
          - title
          - published
          - url
          properties:
            id:
              type: integer
            title:
              type: string
            published:
              type: boolean
              description: Whether learners can see the course.
            url:
              type: string
              description: The course's page in Training
              absolute.:
            cover_checksum:
              type: string
              nullable: true
              description: The base64 MD5 of the cover this source sent (`PUT …/cover`),
                or null when the course shows another image or none.
        versions:
          type: object
          required:
          - published
          - draft
          properties:
            published:
              type: integer
              nullable: true
              description: The number of the live version; null before the first publish.
            draft:
              type: integer
              nullable: true
              description: The number of the draft a publish is working in; null when
                none is open.
        build:
          type: object
          description: The build this server runs, so a source can wait until what
            it filmed is live before it publishes (the fleet's training courses).
          properties:
            commit:
              type: string
              nullable: true
              description: The full commit the running build was made from; null where
                the server does not know it (a development server).
        lessons:
          type: array
          description: The source's own lessons in the draft when one is open, otherwise
            in the live version — in course order. Lessons someone added by hand in
            Training are not listed.
          items:
            "$ref": "#/components/schemas/ManagedCourseLesson"
    ManagedCourseLesson:
      type: object
      required:
      - key
      - kind
      - title
      - quiz_questions
      properties:
        key:
          type: string
        kind:
          type: string
          enum:
          - video
          - document
        title:
          type: string
        video_checksum:
          type: string
          nullable: true
          description: MD5 of the stored video
          base64.:
        captions_checksum:
          type: string
          nullable: true
          description: MD5 of the stored captions (as WebVTT)
          base64.:
        document_checksum:
          type: string
          nullable: true
          description: MD5 of the stored document
          base64.:
        video_seconds:
          type: integer
          nullable: true
          description: The film's length in seconds
          as its source sent it; null for a document.:
        transcript_md5:
          type: string
          nullable: true
          description: MD5 of the film's transcript
          hex; null when it has none.:
        quiz_questions:
          type: integer
          description: How many questions the quiz after this lesson has; 0 when it
            has none.
    ManagedCourseFields:
      type: object
      description: The course's own details. Only the fields sent are applied; a field
        sent as null is cleared.
      properties:
        title:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          nullable: true
          description: A Training category by name; created when the business has
            none by that name.
        difficulty_level:
          type: string
          nullable: true
        learning_objectives:
          type: array
          nullable: true
          items:
            type: string
    ManagedCourseLessonInput:
      type: object
      description: The `lesson` field of `PUT /training/managed_courses/{key}/lessons/{lesson_key}`,
        sent as a JSON string.
      required:
      - kind
      - title
      properties:
        kind:
          type: string
          enum:
          - video
          - document
        title:
          type: string
        description:
          type: string
          nullable: true
        required:
          type: boolean
          description: Whether the lesson counts towards finishing the course. Left
            out, a video counts and a document does not.
        ai_assisted:
          type: boolean
          description: True when AI wrote or narrated this lesson. Left out
          the lesson keeps its mark.:
        duration_seconds:
          type: integer
          minimum: 1
          maximum: 86400
          description: The video's length. Required for a video lesson.
        transcript:
          type: string
          nullable: true
          description: The video's transcript, kept as sent. Left out with a new video,
            Training transcribes it.
        chapters:
          type: array
          description: Where each part of the video starts. The player lists them
            and moves to the one pressed. In order, and none after the video's end.
          items:
            type: object
            required:
            - start
            - title
            properties:
              start:
                type: number
                description: Seconds from the start of the video.
              title:
                type: string
        estimated_minutes:
          type: integer
          description: Reading time of a document lesson.
        quiz:
          type: object
          nullable: true
          description: The quiz that follows the lesson. Left out or null, the lesson
            has no quiz.
          required:
          - questions
          properties:
            title:
              type: string
            passing_score:
              type: integer
              minimum: 0
              maximum: 100
              description: 'Percent needed to pass. Left out: the score the quiz already
                has, or 70.'
            questions:
              type: array
              minItems: 1
              maxItems: 20
              items:
                type: object
                required:
                - text
                - choices
                - correct
                - explanation
                properties:
                  text:
                    type: string
                  choices:
                    type: array
                    minItems: 2
                    maxItems: 6
                    items:
                      type: string
                  correct:
                    type: integer
                    description: The position of the right choice
                    counting from 0.:
                  explanation:
                    type: string
                    description: Why that choice is right — shown to the learner after
                      they answer.
    TrainingQuizEnrollmentState:
      type: object
      nullable: true
      description: |-
        The caller's standing in the COURSE the quiz belongs to. Every quiz endpoint requires an enrollment, so this is null only in the degenerate case where one has been removed between requests.

        The SCORM card endpoints report the same block from the same fields, and reference this schema rather than restating it.
      properties:
        id:
          type: integer
          example: 5512
        status:
          type: string
          enum:
          - enrolled
          - in_progress
          - completed
          - cancelled
          - fulfilled
          description: "**This is the field that routes a client.** Show a course-completion
            screen for `completed`; `fulfilled` means the original assignment was
            satisfied through an approved language course and carries no copied quiz
            result. Note a learner can sit at `progress_percentage: 100` and still
            be `in_progress` — passing a required quiz is a separate condition from
            finishing the lessons, so do not treat 100% as completion."
        progress_percentage:
          type: integer
          example: 83
        completed_at:
          type: string
          format: date-time
          nullable: true
    TrainingQuizCourseState:
      type: object
      description: Where the learner now stands in the course — returned by submit
        and results, because passing an assessment can complete the course and a client
        needs to know that without a second request.
      properties:
        enrollment:
          "$ref": "#/components/schemas/TrainingQuizEnrollmentState"
        certificate:
          type: object
          description: A STATE, not just an id, because issuing a certificate is asynchronous.
            A client that has just submitted a passing final attempt will legitimately
            see `pending`; poll `GET /training/my_records/certificates` rather than
            reading a missing id as "this course has no certificate".
          properties:
            status:
              type: string
              enum:
              - issued
              - pending
              - not_applicable
              description: "`not_applicable` while the enrollment is not yet complete;
                `pending` once it is but the certificate job has not finished; `issued`
                when `id` is present."
            id:
              type: integer
              nullable: true
              example: 8801
    TrainingQuizCard:
      type: object
      description: The assessment entry point — see `GET .../lessons/{lesson_id}/quiz`.
      properties:
        lesson_id:
          type: integer
          nullable: true
          example: 412
        lesson_title:
          type: string
          nullable: true
          example: Safety Assessment
        quiz:
          type: object
          properties:
            id:
              type: integer
              example: 77
            title:
              type: string
              example: Safety Assessment
            description:
              type: string
              nullable: true
            instructions:
              type: string
              nullable: true
              description: Authored pre-flight copy ("you may not pause this", "have
                your SDS sheet to hand"). RENDER IT before the learner starts — it
                is instructions they were meant to read first, not decoration.
            question_count:
              type: integer
              example: 7
            total_points:
              type: integer
              example: 12
            passing_score:
              type: integer
              description: Percent needed to pass.
              example: 80
            time_limit_minutes:
              type: integer
              nullable: true
              description: Null on an untimed quiz — draw no timer at all rather than
                a zero.
              example: 15
            question_display:
              type: string
              enum:
              - one_per_page
              - all_at_once
              description: 'How the quiz was authored to be taken: a stepper, or every
                question on one page. Both post the identical `answers` payload.'
            shuffled:
              type: boolean
              description: Whether question ORDER is shuffled per learner.
        attempts:
          type: object
          properties:
            taken:
              type: integer
              description: TERMINAL attempts used (submitted or timed out). An attempt
                still open is the learner's current one, not a used one.
              example: 1
            max:
              type: integer
              nullable: true
              description: Null when unlimited.
              example: 3
            remaining:
              type: integer
              nullable: true
              description: "**Null, not 0, when there is no attempt limit** — an unlimited
                pool has no remainder, and 0 would read as exhausted."
              example: 2
            can_attempt:
              type: boolean
              example: true
            block_reason:
              type: string
              nullable: true
              description: 'Null when nothing blocks. Otherwise a learner-facing sentence
                naming which of the three gates stopped them: the pool is used up,
                they have already passed and the quiz only allows retakes after a
                fail, or a cooldown is running. Display it verbatim.'
              example: You've used all your attempts for this quiz.
            next_available_at:
              type: string
              format: date-time
              nullable: true
              description: When a cooldown lifts, so a client can count down instead
                of polling to discover the gate has gone. Null when no cooldown is
                running.
        passed:
          type: boolean
          description: Whether ANY attempt in THIS enrollment has passed — scoped
            to the enrollment, never the learner's lifetime, so a re-assigned course
            is not pre-passed by last year's attempt.
        outcome:
          type: object
          nullable: true
          description: The learner's standing result, or null before anything is graded.
            This is the attempt their Pass/Fail VERDICT came from — **not** necessarily
            the most recent one. On a `highest` score policy a failing retake leaves
            the verdict here on the earlier pass, so do not infer it from the newest
            attempt.
          properties:
            attempt_id:
              type: integer
              example: 9014
            attempt_number:
              type: integer
              example: 1
            score_percentage:
              type: integer
              nullable: true
              example: 86
            passed:
              type: boolean
            timed_out:
              type: boolean
            submitted_at:
              type: string
              format: date-time
              nullable: true
            effective_score:
              type: integer
              nullable: true
              description: The score that COUNTS toward completion and the transcript,
                under the quiz's score policy (highest / latest / average / first).
                Can differ from `score_percentage` above.
              example: 86
        open_attempt:
          type: object
          nullable: true
          description: |-
            An attempt still in progress, or null. Its clock has been running since `started_at` and has NOT paused.

            An attempt can be still-open and already out of time, in which case it cannot be resumed — see `expired`. You no longer have to special-case that yourself: `cta.action` reports `view_results` for it, so following the CTA is correct on its own. `expired` is here so you can say WHY ("your previous attempt ran out of time") rather than to decide what the button does.
          properties:
            id:
              type: integer
              example: 9014
            attempt_number:
              type: integer
              example: 2
            started_at:
              type: string
              format: date-time
              nullable: true
            current_position:
              type: integer
              nullable: true
              example: 3
            answered_count:
              type: integer
              example: 3
            time_remaining_seconds:
              type: integer
              nullable: true
              example: 742
            expired:
              type: boolean
              description: |-
                True when this attempt is still `in_progress` but its clock has run out — the learner walked away and the timer did not pause. **It is NOT resumable.** `POST .../quiz/attempts` will grade it from its saved answers and answer 409 `attempt_timed_out` with its id rather than returning a playable quiz, so label the control "View result" and go straight to that attempt's results.

                The card is a read and deliberately does not finalize the attempt itself, which is why it reports the fact instead. Always false on an untimed quiz.
              example: false
        cta:
          type: object
          description: The ONE primary action, resolved server-side so every client
            offers the same thing. See the endpoint description for the table of actions.
          properties:
            action:
              type: string
              enum:
              - start
              - resume
              - retake
              - view_results
              - locked
            attempt_id:
              type: integer
              nullable: true
              description: The attempt to resume, retake from, or show results for.
            reason:
              type: string
              nullable: true
              description: Present on `locked` and `view_results` — why no further
                attempt is allowed.
    TrainingQuizQuestion:
      type: object
      description: |-
        ONE question as the LIVE PLAYER sees it. **Contains no correct answer, in any form** — read the answer-stripping contract at the top of this section, then switch on `question_type` and read the ONE type-specific block that applies:

        `multiple_choice` / `multiple_select` → `options`

        `true_false` → nothing (render the two)

        `text` → `text_input`

        `ranking` → `items` (already shuffled; render in the order given)

        `matching_text` / `matching_image` → `pairs` + `choices` (tokens)

        `hotspot` → `hotspot` (no regions)
      properties:
        id:
          type: integer
          description: The SEALED EDITION's question id. **Key your `answers` object
            by this** — not by an id from the course detail or a previous attempt.
          example: 30455
        position:
          type: integer
          description: 1-based
          for "Q3 of 7". example: 3
        question_type:
          type: string
          enum:
          - multiple_choice
          - true_false
          - multiple_select
          - text
          - ranking
          - matching_text
          - matching_image
          - hotspot
        type_label:
          type: string
          description: Learner-facing name of the type, e.g. `Multiple choice`.
        question_text:
          type: string
        points:
          type: integer
          example: 2
        mandatory:
          type: boolean
          description: Must be answered before the attempt can be submitted. The server
            enforces this too (422 `unanswered_mandatory`), so a client gate is a
            courtesy, not the rule.
        auto_graded:
          type: boolean
          description: 'Whether the SERVER grades it. False only for `text`: a written
            answer is not scored, and no staff screen shows it. Say so ("not scored")
            rather than implying an instant score or promising an instructor review.'
        answered:
          type: boolean
          description: Whether `answer` counts as a real answer, judged per type (a
            `true_false` of `false` counts; a `matching_*` needs one filled pair).
            The same check the mandatory gate on submit uses.
        answer:
          nullable: true
          description: 'What the learner has already entered, **in the same form you
            post it**, so a resumed attempt rehydrates. Per type: an option id string
            (`multiple_choice`), `"true"`/`"false"`, an array of option ids (`multiple_select`),
            a string (`text`), an ordered array of item ids (`ranking`), `{ "<pair
            id>": "<token>" }` (`matching_*` — TOKENS, not the ids stored internally),
            or an array of `{x, y}` percent points (`hotspot`). Null before anything
            is entered.'
        options:
          type: array
          description: "`multiple_choice` and `multiple_select` only. Nothing marks
            the correct one."
          items:
            type: object
            properties:
              id:
                type: string
                example: o2
              text:
                type: string
        text_input:
          type: object
          description: "`text` only — how to draw the box and what length to accept."
          properties:
            min_length:
              type: integer
              nullable: true
              example: 20
            max_length:
              type: integer
              nullable: true
              example: 500
            mode:
              type: string
              nullable: true
              enum:
              - short
              - long
              description: Null for a question authored before the setting existed
                — pick your own default.
        items:
          type: array
          description: "`ranking` only. **Already in the order to display**, and that
            order is a derangement of the authored (correct) one — no item sits in
            its correct slot. It is stable for the attempt, and becomes the learner's
            own arrangement once they have made one. Never sort it. Post the ids back
            in the order the learner leaves them."
          items:
            type: object
            properties:
              id:
                type: string
                example: s3
              text:
                type: string
        pairs:
          type: array
          description: "`matching_text` / `matching_image` only — the LEFT column,
            the fixed prompts, in authored order. `image_url` is present for `matching_image`
            (a 250x150 crop, so every tile is the same shape) and absent otherwise."
          items:
            type: object
            properties:
              id:
                type: string
                example: l2
              left:
                type: string
              image_url:
                type: string
                nullable: true
        choices:
          type: array
          description: '`matching_text` / `matching_image` only — the RIGHT column,
            shuffled, and addressed **only** by opaque `token`. There is deliberately
            no id here to correlate with `pairs[].id`, because that correlation is
            the answer. Post `{ "<pairs[].id>": "<choices[].token>" }`; the server
            maps the token back. Tokens are stable for the question, so an autosaved
            answer round-trips.'
          items:
            type: object
            properties:
              token:
                type: string
                example: 4f2c91ab77e30d15
              text:
                type: string
        hotspot:
          type: object
          description: "`hotspot` only. **The correct regions are absent entirely**
            — not shuffled, not partial. Post the learner's taps as an array of `{x,
            y}` percentages of the image (0-100, origin top-left) and the server decides
            correctness by point-in-region."
          properties:
            image_url:
              type: string
              nullable: true
              description: The background to tap on. Null if the admin never uploaded
                one.
            max_markers:
              type: integer
              description: How many markers the learner may place (the number of regions
                authored).
              example: 1
            rule:
              type: string
              enum:
              - any_one
              - all
              description: 'What the count MEANS, and it changes the task: `any_one`
                needs one region hit, `all` needs every region covered. Say which
                in the prompt — otherwise the server can fail a learner over a distinction
                the screen never mentioned.'
    TrainingQuizAttempt:
      type: object
      description: A LIVE attempt and everything needed to render the player.
      properties:
        answers_revision:
          type: integer
        operation_id:
          type: string
          format: uuid
          nullable: true
        id:
          type: integer
          example: 9014
        attempt_number:
          type: integer
          example: 2
        state:
          type: string
          enum:
          - in_progress
          - submitted
          - timed_out
          description: Always `in_progress` from the player endpoint, which refuses
            the other two.
        started_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: The absolute instant the clock runs out. **Count down against
            this**, not by decrementing `time_remaining_seconds` locally — a device
            that slept will drift, and the server does not accept a late submission
            on trust. Null on an untimed quiz.
        time_remaining_seconds:
          type: integer
          nullable: true
          description: Seconds left as of this response; what to show immediately.
            Null on an untimed quiz.
          example: 742
        current_position:
          type: integer
          nullable: true
          description: The question the learner was last on, written by autosave —
            so a resume reopens there rather than back at question 1.
          example: 3
        question_count:
          type: integer
          example: 7
        answered_count:
          type: integer
          example: 3
        quiz:
          type: object
          description: The EFFECTIVE settings — frozen with this attempt's edition,
            not read live. An admin changing the passing score or time limit mid-attempt
            cannot move this learner's bar or clock, so these are the figures to display.
          properties:
            id:
              type: integer
              example: 77
            title:
              type: string
              example: Safety Assessment
            passing_score:
              type: integer
              example: 80
            time_limit_minutes:
              type: integer
              nullable: true
              example: 15
            question_display:
              type: string
              enum:
              - one_per_page
              - all_at_once
            total_points:
              type: integer
              example: 12
            attempt_number:
              type: integer
              example: 2
            max_attempts:
              type: integer
              nullable: true
              example: 3
        questions:
          type: array
          description: Every question, in the order frozen onto this attempt when
            it started. Render them in this order; it does not change across re-reads.
          items:
            "$ref": "#/components/schemas/TrainingQuizQuestion"
    TrainingQuizAnswers:
      type: object
      description: |-
        The learner's answers, keyed by the question ids **this attempt's player returned** (the sealed edition's ids — see the section header). Ids from anywhere else will be reported as unanswered.

        One value shape per question type:

        `multiple_choice` → the option id, as a string (`"o2"`)

        `true_false` → `"true"` or `"false"`

        `multiple_select` → array of option ids (`["h1","h2"]`)

        `text` → the answer string

        `ranking` → array of item ids in the learner's order (`["s2","s1","s4","s3"]`)

        `matching_text` / `matching_image` → `{ "<pair id>": "<choice token>" }`

        `hotspot` → array of `{x, y}` percent points. May also be sent as a JSON STRING of that array, which is what the web player posts; both are accepted.

        Send the whole object on every autosave — it REPLACES what was stored, it is not merged.
      additionalProperties: true
      example:
        '30455': o2
        '30456':
        - h1
        - h2
        '30457': Because stored energy can start the machine unexpectedly.
        '30458':
        - s2
        - s1
        - s4
        - s3
        '30459':
          l1: 4f2c91ab77e30d15
          l2: 9b70ee2c1d448a03
        '30460':
        - x: 42.5
          "y": 63.1
    TrainingQuizAnswerReview:
      type: object
      description: One row of the post-submit review. The only place `correct_answer`
        and `explanation` are ever emitted.
      properties:
        question_id:
          type: integer
          example: 30455
        position:
          type: integer
          example: 1
        question_type:
          type: string
          enum:
          - multiple_choice
          - true_false
          - multiple_select
          - text
          - ranking
          - matching_text
          - matching_image
          - hotspot
        type_label:
          type: string
          example: Multiple choice
        question_text:
          type: string
        points:
          type: integer
          example: 1
        points_earned:
          type: integer
          example: 1
        status:
          type: string
          enum:
          - correct
          - incorrect
          - pending_review
          description: '**`pending_review` is not a failure.** The key keeps its name
            for compatibility; it means "not scored". A `text` answer is never graded
            (no grading step exists) and is excluded from both sides of the score,
            so render it as "Not scored" — showing it as incorrect tells the learner
            they failed a question nobody grades.'
        your_answer:
          type: string
          description: The learner's answer, already rendered for display per type
            — `"A → B → C"` for a ranking, `"3 of 4 pairs correct"` for a matching,
            `"2 areas marked"` for a hotspot, and the literal `"No answer"` when they
            left it blank (so a row never shows an empty space the learner has to
            interpret).
        correct_answer:
          type: string
          nullable: true
          description: The expected answer, rendered per type. **Null for `text`**
            — there is no single correct answer to show.
        explanation:
          type: string
          nullable: true
          description: The author's note on why, when one was written.
    TrainingQuizResult:
      type: object
      description: A GRADED attempt — the post-submit results screen.
      properties:
        attempt:
          type: object
          properties:
            id:
              type: integer
              example: 9014
            attempt_number:
              type: integer
              example: 1
            state:
              type: string
              enum:
              - submitted
              - timed_out
            timed_out:
              type: boolean
              description: 'A persisted OUTCOME, not a clock reading: the attempt
                was finalized because time ran out, and was graded from the last autosave.
                Say "time''s up" rather than presenting it as a normal submission.'
            submitted_at:
              type: string
              format: date-time
              nullable: true
            time_taken_seconds:
              type: integer
              nullable: true
              example: 421
            formatted_time_taken:
              type: string
              example: 7m 1s
        score:
          type: object
          description: '**The three counts are separate on purpose.** Written (`text`)
            answers are not scored and are excluded from both the numerator and the
            denominator, so compose any "N of M correct" sentence from `correct_count`
            / `auto_graded_count` and report `pending_review_count` alongside. A single
            fused figure would count a question nobody grades and contradict the review
            list below it.'
          properties:
            score_percentage:
              type: integer
              nullable: true
              description: 'Null when the quiz has nothing auto-gradable (written
                answers only): the attempt completes passed with no score. Render
                "Not scored".'
              example: 86
            passed:
              type: boolean
            points_earned:
              type: integer
              example: 10
            total_points:
              type: integer
              description: Auto-gradable points on offer — the denominator of `points_earned`.
              example: 12
            passing_score:
              type: integer
              description: The bar this attempt was actually graded against, frozen
                with its edition — print this even if the quiz has since moved.
              example: 80
            correct_count:
              type: integer
              example: 6
            incorrect_count:
              type: integer
              example: 0
            auto_graded_count:
              type: integer
              description: The denominator of correct/incorrect — auto-gradable questions
                only.
              example: 6
            pending_review_count:
              type: integer
              description: Written answers — not scored, and no staff screen shows
                them. The name is kept for compatibility; the score above is final
                and these rows never join it.
              example: 1
            effective_score:
              type: integer
              nullable: true
              description: The score that COUNTS toward completion and the transcript,
                per the quiz's score policy. On a `highest` quiz a failing retake
                leaves this at the earlier pass, so do not infer it from `score_percentage`.
              example: 86
        review:
          type: array
          nullable: true
          description: Per-question outcome, or **null** when the attempt's edition
            had answer reveal off. Null is not an error and not an empty result —
            render `review_hidden_reason` instead of an empty list.
          items:
            "$ref": "#/components/schemas/TrainingQuizAnswerReview"
        review_hidden_reason:
          type: string
          nullable: true
          enum:
          - answers_not_revealed
          description: Null when `review` is present.
        attempts:
          type: object
          description: The attempt pool, and every graded attempt for the "your attempts"
            switcher.
          properties:
            taken:
              type: integer
              example: 2
            max:
              type: integer
              nullable: true
              example: 3
            remaining:
              type: integer
              nullable: true
              example: 1
            graded:
              type: array
              description: Newest first. Every graded attempt is listed, including
                ones the standing verdict did NOT come from — without this a learner
                could only reach them by guessing an id. `current` flags the one being
                shown.
              items:
                type: object
                properties:
                  id:
                    type: integer
                  attempt_number:
                    type: integer
                  score_percentage:
                    type: integer
                    nullable: true
                  passed:
                    type: boolean
                  timed_out:
                    type: boolean
                  submitted_at:
                    type: string
                    format: date-time
                    nullable: true
                  current:
                    type: boolean
        retake:
          type: object
          description: Whether to offer a Retake at all. Read from the same gates
            as the card, so the two surfaces cannot disagree.
          properties:
            allowed:
              type: boolean
            reason:
              type: string
              nullable: true
              description: Learner-facing sentence when not allowed; display verbatim.
            next_available_at:
              type: string
              format: date-time
              nullable: true
    TrainingOfflineLesson:
      type: object
      description: One lesson packaged for storage on the device. Everything here
        is either the body itself or something the client needs to make that body
        work with no server.
      properties:
        content_type:
          type: string
          enum:
          - text
          - video
          - document
          - quiz
          - scorm
          - partner_course
          - practice
          - checklist
          example: text
        offline_supported:
          type: boolean
          description: Whether this lesson can be stored and rendered with no connection.
            COMPUTED HERE ON PURPOSE — a client could derive it from `content_type`
            plus the video provider, but then every platform re-implements the rule
            and they drift. Same argument as `can_register` on the sessions endpoints.
          example: true
        offline_blocked_by:
          type: string
          nullable: true
          enum:
          - online_only_runtime
          - external_video
          description: WHY it cannot go offline, so the client can say so rather than
            silently omitting the lesson. `online_only_runtime` — a `scorm` or `partner_course`
            lesson, whose runtime is not ours to ship; a `practice` lesson (scored
            by Practice Hub) or a `checklist` lesson (signed off by an evaluator);
            or a TIMED `quiz` (an untimed quiz IS offline-capable — see `quiz`). `external_video`
            — a YouTube/Vimeo embed. Null when `offline_supported` is true.
          example:
        description:
          type: string
          nullable: true
          description: The author's lesson description, sanitised like `html`, for
            EVERY lesson type — the line the online lesson page prints under its eyebrow.
            `html` folds it in only for non-text lessons, so a stored text lesson
            reads it from here. Null when the author left it blank.
          example: Configure your physical locations
        quiz:
          type: object
          nullable: true
          description: 'For an UNTIMED quiz lesson only: the quiz''s sealed edition,
            answer-stripped exactly as the live player is (no correct answers, matching
            addressed by opaque tokens, hotspot regions withheld), so the assessment
            can be taken with no connection and replayed through `POST /training/quiz_attempts/offline`.
            Key that replay''s `answers` by these question ids. Null for every other
            lesson and for a timed quiz.'
          properties:
            id:
              type: integer
            version_id:
              type: integer
              description: The sealed edition the ids belong to.
            title:
              type: string
            instructions:
              type: string
              nullable: true
            passing_score:
              type: integer
            question_display:
              type: string
              enum:
              - one_per_page
              - all_at_once
            question_count:
              type: integer
            question_ids:
              type: array
              nullable: true
              items:
                type: integer
              description: 'Present only for a quiz that draws a set number of questions
                per attempt (a question pool): the ids of the questions drawn for
                this learner, which are the only ones in `questions`. Send it back
                as `question_ids` when replaying the attempt so it is scored on the
                questions the device showed. Null for every other quiz.'
            max_attempts:
              type: integer
              nullable: true
            questions:
              type: array
              items:
                "$ref": "#/components/schemas/TrainingQuizQuestion"
        html:
          type: string
          nullable: true
          description: |-
            The lesson body, SANITISED through the same allowlist the web renders with (`training_rich_text`), so offline and online show byte-identical markup and the allowlist has one definition. Never the raw column: raw author markup can carry a `<script>`, an `onerror=` or a `javascript:` href, and an offline copy re-executes it every time the lesson opens — including when nothing can reach the device to revoke it.

            For a TEXT lesson this is the authored body. For every other type it is the lesson's description, which the client renders above whatever native surface it builds for the media. Null when there is neither.
          example: "<p>Before servicing any powered equipment…</p>"
        stylesheets:
          type: array
          description: |-
            Absolute, digested URLs of the CSS this html needs — download each and rewrite the `<link>` hrefs. The SAME files the online webview links.

            TEXT LESSONS GET A THIRD ENTRY (`custom.css`) and the others do not: it is 438 KB and carries only the `.lesson-content` rules, which exist because the editor emits bullet AND numbered lists as `<ol>` — without them every bullet list an author typed renders as "1. 2. 3.". A client syncing video or document lessons should not pay for it.
          items:
            type: string
          example:
          - https://officechat.workforce.mangoapps.com/assets/mobile_tailwind-8f3a.css
          - https://officechat.workforce.mangoapps.com/vendor/fontawesome-pro-6.7.2-web/css/all.min.css
        document:
          type: object
          nullable: true
          description: |-
            The lesson's attached document, for a `document` lesson. A FIELD, not markup — it is a lesson-level attachment and was never part of the body, so parsing the html could not find it. Render it with the platform's own viewer.

            `url` IS SIGNED AND EXPIRING — download it, then point the stored copy at the local file. Null when nothing is attached.
          properties:
            url:
              type: string
              example: https://officechat.workforce.mangoapps.com/rails/active_storage/blobs/redirect/xyz/handbook.pdf
            filename:
              type: string
              example: handbook.pdf
            content_type:
              type: string
              example: application/pdf
            byte_size:
              type: integer
              example: 284913
        video:
          type: object
          nullable: true
          description: The lesson's video, for a `video` lesson. Read `downloadable`
            FIRST — the two cases have different fields and only one can go offline.
          properties:
            downloadable:
              type: boolean
              description: True for an UPLOADED video (url/filename/byte_size present).
                False for an embed, where only `provider` and `embed_url` are.
              example: true
            url:
              type: string
              nullable: true
              description: Signed and expiring
              same as `document.url`.:
            filename:
              type: string
              nullable: true
            content_type:
              type: string
              nullable: true
            byte_size:
              type: integer
              nullable: true
            duration_seconds:
              type: integer
              nullable: true
              example: 742
            provider:
              type: string
              nullable: true
              description: Present only when not downloadable.
              example: youtube
            embed_url:
              type: string
              nullable: true
              description: Present only when not downloadable — requires a connection.
        updated_at:
          type: string
          format: date-time
          nullable: true
          description: When the lesson was last edited. Compare against what you stored
            to decide whether a re-sync needs to re-download this lesson at all.
          example: '2026-08-28T09:14:00Z'
    TrainingLessonFeedback:
      type: object
      properties:
        learner_name:
          type: string
        course_version_id:
          type: integer
        course_version_number:
          type: integer
        source_digest:
          type: string
        video_position_seconds:
          type: integer
          nullable: true
        can_close:
          type: boolean
        id:
          type: integer
        training_lesson_id:
          type: integer
        reaction:
          type: string
          enum:
          - useful
          - unclear
          - outdated
        reason:
          type: string
          nullable: true
        note:
          type: string
          nullable: true
          maxLength: 500
        status:
          type: string
          enum:
          - new
          - in_review
          - answered
          - updated
          - closed
        response:
          type: string
          nullable: true
          maxLength: 2000
        responded_at:
          type: string
          format: date-time
          nullable: true
        assignee_id:
          type: integer
          nullable: true
        corrected_lesson_id:
          type: integer
          nullable: true
        can_respond:
          type: boolean
        can_edit_note:
          type: boolean
        can_undo:
          type: boolean
    TrainingLessonProgressInput:
      type: object
      description: Additive compatible progress contract. Legacy fields remain optional.
        Exact resume_position requires operation_id and base_revision. Any request
        using operation_id, base_revision or resume_position must include enrollment_id
        from the content checkpoint; a replacement enrollment is rejected with 409.
        Send progress_generation from the same checkpoint to detect resets. Omission
        is accepted only for generation zero when using the protected protocol. Legacy
        unversioned writes may omit enrollment_id. Retries of the identical operation
        never count time twice. A stale exact revision returns 409; reload rather
        than overwriting another device's cursor.
      properties:
        enrollment_id:
          type: integer
          description: Enrollment from the content checkpoint; required for revision/operation
            saves.
        progress_generation:
          type: integer
          minimum: 0
          description: Reset generation from the content checkpoint; required after
            the first reset.
        resume_position:
          type: integer
          minimum: 0
          maximum: 43200
          description: Exact cursor
          including backward seeking.:
        base_revision:
          type: integer
          minimum: 0
          description: Revision returned by content/progress GET or last successful
            write.
        operation_id:
          type: string
          format: uuid
          description: Keep unchanged until the identical operation is acknowledged.
        completed:
          type: boolean
          description: "`true` completes the lesson. There is no `false` — completion
            is monotonic, so sending false does not un-complete (see the endpoint
            description). Safe to send repeatedly."
          example: true
        video_position:
          type: integer
          description: Seconds into the video. Stored as the MAX of this and what
            is already recorded, so an out-of-order flush cannot rewind the learner.
          example: 120
        time_spent:
          type: integer
          description: Seconds spent in THIS sitting — a delta, added to the stored
            total, not a total itself. Omit it and the server records wall-clock time
            from first open to completion instead (what the web records); send it
            and your figure wins, which is what an offline client wants, since a flush
            can arrive days after the lesson was finished.
          example: 300
    TrainingLessonProgress:
      type: object
      description: The lesson's progress AFTER the write, plus the two enrollment
        numbers a client would otherwise refetch to update the course row it came
        from.
      properties:
        enrollment_id:
          type: integer
        progress_generation:
          type: integer
          minimum: 0
        course_version_id:
          type: integer
        resume_position:
          type: integer
          nullable: true
        revision:
          type: integer
        operation_id:
          type: string
          format: uuid
          nullable: true
        lesson_id:
          type: integer
          example: 91
        completed:
          type: boolean
          example: true
        completed_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-09-01T12:35:49Z'
        video_position:
          type: integer
          nullable: true
          description: The stored value
          which may be HIGHER than what you just sent (max wins).:
          example: 120
        time_spent_seconds:
          type: integer
          nullable: true
          example: 300
        score:
          type: integer
          nullable: true
          description: Read-only — written by SCORM/xAPI callbacks and partner-content
            sync. A progress write never sets it; a learner does not grade themselves.
          example: 88
        course_progress_percentage:
          type: integer
          description: The enrollment's recalculated percentage — so a client can
            update the course card without a second call.
          example: 62
        course_status:
          type: string
          enum:
          - enrolled
          - in_progress
          - completed
          - cancelled
          - fulfilled
          example: in_progress
    TrainingSessionRegistrationSummary:
      type: object
      nullable: true
      description: |-
        The caller's hold for the course in WHATEVER state — including `attended` and `completed`, which the per-row `cta_action` deliberately ignores so a past attendance cannot make every other row offer "switch". Null when the caller has no live hold.

        **This block is self-contained on purpose.** It describes a session that may not be anywhere else in the response: the session list is `scheduled` + `upcoming`, so a seat the learner has already ATTENDED is in the past and appears in no list. It therefore carries the session's title, type and full time — never just `session_id`, which a client would have no row to resolve against.

        **Print `formatted_date` and `formatted_time`.** They are composed server-side in the VENUE's zone and are ready to render — an instructor-led session happens in a specific place, so that zone is the one that matters, never the viewer's. Formatting `starts_at` yourself (a bare `new Date(starts_at)`) renders in the device's zone and shows a learner in another country a time the session does not start at.

        Every instant here carries the VENUE's UTC offset. `timezone` is for calendar entries and zone-aware arithmetic, not for display; `timezone_label` duplicates the abbreviation that already closes `formatted_time` and is retained only for compatibility.

        `starts_at_local` / `ends_at_local` were emitted until 2026-09-01 and are gone. This is not a reversal of that: the venue-local instant is carried by `starts_at` itself rather than duplicated beside it, so there is still ONE representation of the instant.

        Identical shape from every endpoint that reports a hold: the course detail, the session list, a single session, and the register/cancel responses.
      properties:
        id:
          type: integer
        session_id:
          type: integer
        session_title:
          type: string
          nullable: true
          description: Title of the held session, so it renders without a list row.
        session_type:
          type: string
          nullable: true
          enum:
          - classroom
          - webinar
          - hybrid
        status:
          type: string
          enum:
          - registered
          - waitlisted
          - attended
          - completed
          - no_show
          - cancelled
        waitlisted:
          type: boolean
        waitlist_position:
          type: integer
          nullable: true
        switchable:
          type: boolean
          description: Whether this hold can still be moved or cancelled (registered
            or waitlisted only).
        awaiting_sign_off:
          type: boolean
          description: Attended, but still needs an instructor's sign-off.
        roster_status:
          type: string
          enum:
          - registered
          - waitlisted
          - attended
          - awaiting_sign_off
          - completed
          - checklist_open
          - no_show
          - cancelled
          description: 'The seat''s state as the web roster words it: `status` with
            two derived states — `checklist_open`, a finalized seat whose course still
            waits on its sign-off checklist (it reads `completed` once every item
            is signed), and `awaiting_sign_off`, attendance whose proof is still owed.
            `status` stays the stored value.'
          example: checklist_open
        roster_status_label:
          type: string
          description: The words for `roster_status` ("Checklist open", "Completed",
            …).
          example: Checklist open
        formatted_date:
          type: string
          nullable: true
          description: The held session's date, composed server-side in the VENUE's
            zone and ready to print. Pair with `formatted_time`.
          example: Mon, Jul 20
        formatted_time:
          type: string
          nullable: true
          description: The held session's time range WITH its zone abbreviation, ready
            to print — the same string a session row carries, so one renderer serves
            both.
          example: 9:00 AM – 1:00 PM CDT
        starts_at:
          type: string
          format: date-time
          nullable: true
          description: The instant, carrying the VENUE's UTC offset (not `Z`, and
            not the caller's). For sorting, countdowns and calendar entries; for display
            use `formatted_time`. Corrected 2026-09-16 — it previously serialized
            in the ambient request zone, so it was documented UTC and was in fact
            the caller's offset.
          example: '2026-07-20T09:00:00-05:00'
        ends_at:
          type: string
          format: date-time
          nullable: true
          description: As `starts_at` — the VENUE's offset.
        timezone:
          type: string
          nullable: true
          description: IANA identifier for the session's zone, e.g. `America/Chicago`
            — resolvable by `TimeZone(identifier:)`, `ZoneId.of` and `Intl.DateTimeFormat`.
            Not needed for display; needed for a calendar entry's `TZID` and for arithmetic
            crossing a daylight-saving boundary, which an offset alone cannot support.
            Normalized on the way out as of 2026-09-16 — the stored value is an ActiveSupport
            zone name, so this previously returned unresolvable strings like `International
            Date Line West`.
        timezone_label:
          type: string
          nullable: true
          description: Short form for humans, e.g. `CDT`. REDUNDANT — the same text
            closes `formatted_time`. Retained for compatibility; do not build on it.
        duration_minutes:
          type: integer
          nullable: true
          description: Derived from the two instants; zone-independent.
    TrainingScormCard:
      type: object
      description: The courseware launch panel — see `GET .../lessons/{lesson_id}/scorm`.
        The lesson type is labelled "SCORM" everywhere in the product, but the engine
        behind it plays SCORM 1.2, SCORM 2004, xAPI, cmi5 and AICC; `package.standard`
        is what says which one a given module is.
      properties:
        lesson_id:
          type: integer
          nullable: true
          example: 289
        lesson_title:
          type: string
          nullable: true
          example: Spill Response Simulation
        package:
          type: object
          description: 'The imported, playable form of the module. Null-ish throughout
            for a lesson whose package has never been imported — which is a real state,
            not a bug: see `launchable`.'
          properties:
            standard:
              type: string
              nullable: true
              enum:
              - scorm12
              - scorm2004
              - xapi
              - cmi5
              - aicc
            standard_label:
              type: string
              nullable: true
              description: How the standard is spelled for a learner — `SCORM`, `xAPI`,
                `cmi5`, `AICC`. Print this rather than deriving it from `standard`.
              example: SCORM
            title:
              type: string
              nullable: true
              description: The manifest's own title, which authors routinely write
                differently from the lesson title. Reported BESIDE `lesson_title`,
                not instead of it — the web cards show the lesson's.
            status:
              type: string
              nullable: true
              enum:
              - pending
              - processing
              - ready
              - failed
              description: The LESSON's import state. Diagnostic only — it is not
                the gate, and a tenant carrying lessons from before the native engine
                has them stamped `ready` with no package behind them.
            launchable:
              type: boolean
              description: "**The gate.** True when a launch can actually produce
                a player: the package imported and its files landed (or it is an AICC
                unit that runs on the provider's own server). This is the same check
                the launch itself applies, so render the primary control from here."
              example: true
            multi_unit:
              type: boolean
              description: A menu-driven package (several SCOs / AUs). The player
                owns the menu; the card reports the shape so it can say "6 of 18 sections".
            unit_count:
              type: integer
              nullable: true
              example: 1
            mastery_score:
              type: string
              nullable: true
              description: The bar the manifest sets, verbatim (a string in the manifest).
              example: '80'
            time_limit_seconds:
              type: integer
              nullable: true
              description: The manifest's max time allowed, parsed to seconds. The
                player enforces it; the card reports it so a learner knows before
                starting.
              example: 1200
            error:
              type: string
              nullable: true
              description: Why the import failed, as the importer recorded it.
              example: No imsmanifest.xml in the ZIP
        completion:
          type: object
          nullable: true
          description: 'The learner''s record for this lesson — the Status and Score
            tiles the web cards show. Null until the module is first launched: a card
            view is a read and creates nothing, so an untouched module never reads
            as started.'
          properties:
            completed:
              type: boolean
            completed_at:
              type: string
              format: date-time
              nullable: true
            started_at:
              type: string
              format: date-time
              nullable: true
            score:
              type: integer
              nullable: true
              description: |-
                0–100, projected from the runtime's own score. Null for a package that reports none, which many do — do not render 0 for it.

                **FROZEN once `completed` is true.** The commit that completes the lesson records its score and training time; nothing the runtime reports afterwards can change either. So this figure is final — safe to render as "your score" rather than "your latest score" — and a replay can neither improve nor damage it.
              example: 92
            time_spent_seconds:
              type: integer
              nullable: true
              description: Frozen at completion, like `score`. Before that it is the
                greater of the runtime's reported time and the wall clock since the
                lesson was started, so it never goes backwards.
              example: 900
        resume:
          type: object
          nullable: true
          description: Where the learner is INSIDE the module. Null when there is
            nothing to resume (never launched, or reset), so a fresh card stays fresh.
          properties:
            sentence:
              type: string
              nullable: true
              description: The one line the web cards print, composed server-side
                because the per-standard phrasing rule is the part that drifts — e.g.
                "Resume from Etiquette · 6 of 18 sections done · 42m 00s in".
              example: Resume where you left off · 7m 00s in
            percent:
              type: integer
              nullable: true
              description: 0–100, for the card's progress bar. Null when the package
                reports no measure.
              example: 37
            units_total:
              type: integer
              example: 18
            units_done:
              type: integer
              example: 6
            current_unit_title:
              type: string
              nullable: true
              example: Etiquette
            bookmarked:
              type: boolean
              description: The module reported a location or suspend data, so a relaunch
                resumes.
            finished:
              type: boolean
            time_spent:
              type: string
              nullable: true
              description: Humanised total, e.g. `42m 10s` / `1h 5m`. Null under a
                minute, where it reads as noise rather than progress.
            launches:
              type: integer
              description: How many times the module has been opened. **Not attempts**
                — courseware has no attempt pool; see the endpoint description.
              example: 2
            status:
              type: string
              enum:
              - not_attempted
              - incomplete
              - completed
              - passed
              - failed
              - browsed
              description: The runtime's own status for the registration.
        offline:
          type: object
          description: Always unsupported for courseware — the runtime is server-side,
            so there is nothing a client can store and play offline. Reported on the
            card because the launch control is where a learner decides whether to
            start something with no signal.
          properties:
            supported:
              type: boolean
              example: false
            blocked_by:
              type: string
              nullable: true
              example: online_only_runtime
        cta:
          type: object
          description: The one primary control, resolved server-side so three clients
            cannot each invent their own ladder.
          properties:
            action:
              type: string
              enum:
              - launch
              - resume
              - review
              - processing
              - unavailable
            label:
              type: string
              description: The verb the web cards print for this action.
              example: Launch module
            web_view_url:
              type: string
              nullable: true
              description: The chrome-less runtime to open in a WebView, absolute.
                **Null whenever there is nothing to open** — render the control only
                when it is present. Opening it STARTS the attempt (it mints the registration
                and a single-use session), so never prefetch it.
              example: https://acme.workforce.mangoapps.com/m/apps/training/courses/81/lessons/289/scorm?embed=1
            reason:
              type: string
              nullable: true
              enum:
              - package_processing
              - package_stalled
              - package_unavailable
              - enrollment_cancelled
              description: Why there is nothing to launch. `package_stalled` is an
                import that stopped part-way and will not resume on its own — it needs
                an admin to re-upload, which `message` says.
            message:
              type: string
              nullable: true
              description: Prose written for a learner. Show it verbatim rather than
                mapping the code.
              example: This module is still being prepared. Check back shortly.
            secondary:
              type: object
              nullable: true
              description: '"Start over" (`POST .../scorm/restart`) — discards the
                bookmark so the module plays from the beginning. Present ONLY on an
                unfinished module that has something to discard; null on a `review`
                card, because a completed lesson''s record is frozen and a reset there
                would promise a do-over that cannot be recorded. Null too for an API
                token without destructive:training, which the restart asks.'
              properties:
                action:
                  type: string
                  enum:
                  - restart
                  example: restart
                label:
                  type: string
                  example: Start over
    UploadSession:
      type: object
      properties:
        token:
          type: string
        status:
          type: string
          enum:
          - open
          - composing
          - completed
          - failed
          - expired
          description: |
            `open` (accepting parts) → `composing` (assembling) → `completed` or
            `failed`. `expired` is terminal too: the session ran past
            `expires_at` before it finished, or had failed and was kept past it.
        filename:
          type: string
        byte_size:
          type: integer
          format: int64
        part_size:
          type: integer
          description: Bytes per part (the last part carries the remainder).
        parts_count:
          type: integer
        uploaded_parts:
          type: array
          items:
            type: integer
          description: Part numbers stored so far (while `open`).
        expires_at:
          type: string
          format: date-time
        result:
          type: object
          additionalProperties: true
          description: Present once `completed` — what the door made of the file.
        error:
          type: string
          description: Present when `failed`, and kept on an `expired` session that
            had failed.
    UsersDirectoryRow:
      type: object
      description: One person. A field the caller's directory hides is null (`locations`
        is then an empty list); `uid` and `id` are always present.
      properties:
        uid:
          type: string
          nullable: true
          description: The employee UID (`mango_employee_id`), the key `GET /users/{id}`
            takes.
          example: E1234567
        id:
          type: integer
        email:
          type: string
          nullable: true
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        full_name:
          type: string
        active:
          type: boolean
          description: The person's membership of this business is active.
        job_title:
          type: string
          nullable: true
        department:
          type: string
          nullable: true
        organizational_role:
          type: string
          nullable: true
        hire_date:
          type: string
          format: date
          nullable: true
        original_hire_date:
          type: string
          format: date
          nullable: true
        employment_status:
          type: string
          nullable: true
        employee_type:
          type: string
          nullable: true
          description: The older label derived from `employment_status`, kept for
            existing clients.
        phone:
          type: string
          nullable: true
        manager:
          type: object
          nullable: true
          description: The person's manager in this business.
          properties:
            uid:
              type: string
              nullable: true
            name:
              type: string
            email:
              type: string
              nullable: true
              description: Only where the caller's directory shows email.
        locations:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
              address:
                type: string
                nullable: true
        marketplace_apps:
          type: array
          description: The apps enabled for the business (the same list on every row).
          items:
            type: object
            properties:
              id:
                type: integer
              name:
                type: string
              slug:
                type: string
              category:
                type: string
                nullable: true
        termination_date:
          type: string
          format: date
          nullable: true
        termination_type:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true
        last_activity_at:
          type: string
          format: date-time
          nullable: true
        avatar_url:
          type: string
          nullable: true
          description: An absolute URL — the photo, or a generated initials image.
        avatar_updated_at:
          type: integer
          nullable: true
          description: When the photo was set, in Unix seconds — a cache-busting version;
            null with no photo.
        online:
          type: boolean
          description: Connected to chat (an idle person is `true`).
        presence:
          type: string
          enum:
          - online
          - offline
        presence_state:
          type: string
          enum:
          - online
          - idle
          - offline
    WebhooksWebhook:
      type: object
      description: A registered webhook. Never carries its signing secret.
      required:
      - id
      - business_id
      - url
      - description
      - event_types
      - active
      - last_triggered_at
      - consecutive_failures
      - created_at
      - updated_at
      properties:
        id:
          type: integer
        business_id:
          type: integer
        url:
          type: string
          description: Where events are POSTed.
          example: https://hooks.example.com/workforce
        description:
          type: string
          nullable: true
          description: The webhook's label, shown in Admin → Webhooks. A webhook has
            no separate name.
        event_types:
          type: array
          description: The event names this webhook receives, or `"*"`. A name kept
            from before (a disabled plugin's event, an event whose platform switch
            is now off) stays listed and receives nothing until it can.
          items:
            type: string
          example:
          - leave_request.approved
          - shift.created
        active:
          type: boolean
          nullable: true
          description: Whether deliveries are sent. Only `true` receives events; null
            (stored when an empty value was sent) is off. Switched off automatically
            after five deliveries in a row fail all their attempts.
        last_triggered_at:
          type: string
          format: date-time
          nullable: true
          description: When a delivery last succeeded.
        consecutive_failures:
          type: integer
          description: Real deliveries in a row that failed every attempt (a failed
            test is not counted); reset by a success.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WebhooksWebhookWithSecret:
      description: The webhook as POST /webhooks returns it — the only response with
        the secret.
      allOf:
      - "$ref": "#/components/schemas/WebhooksWebhook"
      - type: object
        required:
        - secret_key
        properties:
          secret_key:
            type: string
            description: The HMAC-SHA256 key behind every delivery's X-Webhook-Signature
              ("sha256=" + hex HMAC of the raw body). Generated as 40 hex characters
              unless the body set one.
    WebhooksWebhookInput:
      type: object
      description: The fields a create or update may set. Any other key is ignored.
      properties:
        url:
          type: string
          description: A full http:// or https:// address with a host, no credentials,
            no
        description:
          type: string
          description: The webhook's label.
        name:
          type: string
          description: Alias for `description`, used only when `description` is not
            sent.
        event_types:
          type: array
          description: Names from GET /webhooks/event_types (`event_types`, or a `plugin_event_types[].event_type`),
            or `["*"]`. Must be an array; a bare string is dropped.
          items:
            type: string
        active:
          type: boolean
          nullable: true
          description: Defaults to true on create. On update, anything that does not
            read as true switches the webhook off (and asks `destructive:webhooks`
            when it was on).
        secret_key:
          type: string
          description: Your own signing secret. Generated when omitted on create;
            never echoed back by an update.
    WebhooksListMeta:
      type: object
      required:
      - current_page
      - total_pages
      - total_count
      properties:
        current_page:
          type: integer
        total_pages:
          type: integer
        total_count:
          type: integer
          description: Active webhooks in the business.
    WebhooksValidationMessages:
      type: object
      description: A refused save, as sentences a person can be shown.
      required:
      - errors
      properties:
        errors:
          type: array
          items:
            type: string
          example:
          - URL must be a full http:// or https:// address, such as https://example.com/webhook
    WebhooksVerifyResult:
      type: object
      required:
      - success
      properties:
        success:
          type: boolean
        response_code:
          type: integer
          description: The receiver's HTTP status. Absent when no response came back
            (the guard refused the URL, or the connection failed).
          example: 200
        error:
          type: string
          description: Why verification failed; absent on success.
          example: HTTP 500
    WebhooksTestResult:
      type: object
      required:
      - success
      - delivery
      - retry
      properties:
        success:
          type: boolean
        retry:
          type: boolean
          description: Always false here — a test makes one attempt.
        error:
          type: string
          description: Why the delivery failed; absent on success.
          example: HTTP 500
        delivery:
          "$ref": "#/components/schemas/WebhooksDelivery"
    WebhooksDelivery:
      type: object
      description: The delivery record the test wrote (every column of the row).
      properties:
        id:
          type: integer
        webhook_id:
          type: integer
        business_id:
          type: integer
        event_type:
          type: string
          example: test.event
        event_id:
          type: string
          format: uuid
          description: Also sent as the X-Webhook-Event-Id header.
        payload:
          type: object
          description: The JSON body that was POSTed.
          properties:
            event_id:
              type: string
              format: uuid
            event_type:
              type: string
            business_id:
              type: integer
            occurred_at:
              type: string
              format: date-time
            timestamp:
              type: string
              format: date-time
            data:
              type: object
              description: For a test, `message`, `timestamp` and `test_id`.
        status:
          type: string
          enum:
          - pending
          - success
          - failed
        response_code:
          type: integer
          nullable: true
        response_body:
          type: string
          nullable: true
          description: The receiver's response body, cut to 5,000 characters.
        error_message:
          type: string
          nullable: true
        attempted_at:
          type: string
          format: date-time
          nullable: true
        delivered_at:
          type: string
          format: date-time
          nullable: true
        attempt_number:
          type: integer
        retry_count:
          type: integer
        max_retries:
          type: integer
          description: 0 for a test.
        next_retry_at:
          type: string
          format: date-time
          nullable: true
        last_retry_at:
          type: string
          format: date-time
          nullable: true
        response_time_ms:
          type: string
          nullable: true
          description: Milliseconds the receiver took, as a decimal string (e.g. "142.35").
        request_url:
          type: string
          description: The URL called, with any user:password@ replaced by [REDACTED].
        request_headers:
          type: string
          nullable: true
          description: The request headers sent (Content-Type, User-Agent, X-Webhook-Signature,
            X-Webhook-Event-Id), stored as text.
        request_body_size:
          type: integer
          nullable: true
        attempt_log:
          type: array
          description: One entry per attempt (at most 20), oldest first.
          items:
            type: object
            properties:
              attempt:
                type: integer
              attempted_at:
                type: string
                format: date-time
              status:
                type: string
                enum:
                - success
                - failed
              response_code:
                type: integer
              error:
                type: string
              response_time_ms:
                type: number
              redirected_to:
                type: string
                description: Where a cross-origin redirect ended (that hop is sent
                  unsigned).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WeeklyReport:
      type: object
      description: A person's report for one period, with its lines by section.
      required:
      - id
      - status
      - status_label
      - period_start
      - period_end
      - due_at
      - sections
      - web_url
      properties:
        id:
          type: integer
          example: 311
        status:
          type: string
          enum:
          - draft
          - submitted
          - skipped
          description: '`submitted` is shown as "Shared". Switch on this key; show
            `status_label`.'
          example: draft
        status_label:
          type: string
          example: Draft
        period_start:
          type: string
          format: date
          example: '2026-10-26'
        period_end:
          type: string
          format: date
          example: '2026-11-01'
        due_at:
          type: string
          format: date-time
          example: '2026-10-30T17:00:00-07:00'
        title:
          type: string
          nullable: true
        mood:
          type: string
          nullable: true
          enum:
          - on_track
          - at_risk
          - blocked
          -
          description: The writer's mood for the week; null when not picked.
          example: on_track
        mood_label:
          type: string
          nullable: true
          description: The mood in words ("Smooth sailing", "A few bumps", "Stuck").
          example: Smooth sailing
        visibility:
          type: string
          enum:
          - default
          - managers_only
          - everyone
          example: default
        visibility_label:
          type: string
          description: Who reads it, in words, as the web says it.
          example: Your team and your managers
        skip_reason:
          type: string
          nullable: true
          enum:
          - leave
          - other
          -
          description: 'Why a skipped report was skipped: `leave` — away all period
            ("On leave"); `other` — its writer skipped it ("You skipped this one").
            Null unless skipped.'
          example:
        submitted_at:
          type: string
          format: date-time
          nullable: true
        edited_at:
          type: string
          format: date-time
          nullable: true
          description: When a shared report was last changed.
        sections:
          type: array
          description: The template's sections, in order, each with its lines.
          items:
            type: object
            required:
            - key
            - title
            - kind
            - items
            properties:
              key:
                type: string
                example: done
              title:
                type: string
                example: Done
              kind:
                type: string
                enum:
                - bullets
                - grouped
                - table
                - personal
                example: bullets
              items:
                type: array
                items:
                  "$ref": "#/components/schemas/WeeklyReportItem"
        web_url:
          type: string
          description: The report's page on the web (relative path).
          example: "/apps/weekly-reports/reports/311"
    WeeklyReportItem:
      type: object
      required:
      - id
      - section_key
      - text
      - position
      properties:
        id:
          type: integer
          example: 812
        section_key:
          type: string
          example: help
        text:
          type: string
          example: Need a second reviewer for the vendor contract
        position:
          type: integer
          example: 1
        plan_outcome:
          type: string
          nullable: true
          enum:
          - open
          - done
          - carried
          - dropped
          -
          description: A plan line's outcome ("Planned", "Done", "Still going", "Let
            it go"); null outside a plan section.
        help_status:
          allOf:
          - "$ref": "#/components/schemas/WeeklyReportHelpStatus"
          nullable: true
          description: An ask's status; null outside the "Could use a hand with" section.
    WeeklyReportHelpStatus:
      type: string
      enum:
      - open
      - offered
      - sorted
      description: "`open` — no offers yet; `offered` — someone offered to help; `sorted`
        — the writer marked it sorted."
      example: offered
    WeeklyReportHelpOffer:
      type: object
      required:
      - item_id
      - help_status
      - new_offer
      - writer
      properties:
        item_id:
          type: integer
          example: 812
        help_status:
          "$ref": "#/components/schemas/WeeklyReportHelpStatus"
        new_offer:
          type: boolean
          description: True when this call recorded the offer (and told the writer);
            false when the caller had already offered.
          example: true
        writer:
          type: object
          required:
          - id
          - name
          description: The ask's writer — whom a client opens a Chat message to.
          properties:
            id:
              type: integer
              example: 77
            name:
              type: string
              example: Priya Shah
    WorkspacePaginationMeta:
      type: object
      description: Pagination state for the page just returned. The same values are
        also sent as the `X-Total-Count`, `X-Total-Pages`, `X-Current-Page` and `X-Per-Page`
        response headers.
      properties:
        total_count:
          type: integer
          description: Rows matching the request, across all pages.
        total_pages:
          type: integer
        current_page:
          type: integer
        per_page:
          type: integer
          description: The page size actually used, after clamping.
        has_next_page:
          type: boolean
        has_prev_page:
          type: boolean
    WorkspaceUserRef:
      type: object
      description: A compact reference to a platform user.
      properties:
        id:
          type: integer
          nullable: true
        email:
          type: string
          nullable: true
          description: Null where the organization's directory hides email from the
            caller.
        name:
          type: string
          nullable: true
          description: The person's name; for a person with no name, their email where
            it may be shown, else "Unnamed member".
    Workspace:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
          description: URL-safe identifier; accepted anywhere an id is.
        name:
          type: string
        description:
          type: string
          nullable: true
        status:
          type: string
          description: The workspace's lifecycle state, e.g. `active` or `archived`.
        icon:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        kind:
          type: string
          nullable: true
          description: What sort of workspace this is — one of the values listed on
            `POST /workspace/workspaces` `kind`. Null when unclassified.
        kind_label:
          type: string
          nullable: true
          description: Human-readable label for `kind`, e.g. "Client account". Null
            when `kind` is.
        tags:
          type: array
          items:
            type: string
          description: The workspace's tags. `[]` when none.
        ends_on:
          type: string
          format: date
          nullable: true
          description: The date the workspace's work ends, if one is set.
        series_key:
          type: string
          nullable: true
          description: Shared by every workspace in the same recurring series (set
            when a workspace is rolled forward into its next edition). Null for a
            workspace that is not part of a series.
        series_cadence:
          type: string
          nullable: true
          enum:
          - yearly
          - quarterly
          - monthly
          -
        location_id:
          type: integer
          nullable: true
          description: The location this workspace belongs to, for a location workspace.
        starts_on:
          type: string
          format: date
          nullable: true
          description: The date the project starts, if one is set.
        health:
          type: string
          enum:
          - on_track
          - at_risk
          - off_track
          - not_set
          description: The owner's own rating of how the project is going; `not_set`
            until rated.
        organizational_department_id:
          type: integer
          nullable: true
          description: The department the project belongs to, if one is set.
        sponsor_id:
          type: integer
          nullable: true
          description: The user id of the project's sponsor, if one is set.
        provisioned_by:
          type: string
          enum:
          - manual
          - location_sync
          - import
          - directory_sync
          description: How the workspace was created — by a person, or by one of the
            automatic provisioners.
        owner_id:
          type: integer
          description: 'The workspace''s primary owner — its creator, or whoever it
            was last handed to. A workspace can have SEVERAL owners: every member
            whose membership role is `owner` holds the same rights (manage members,
            review held posts, archive, export, delete), so the caller''s own `role`
            — not this id — says whether they own it.'
        role:
          type: string
          nullable: true
          description: The CALLER's role in this workspace — e.g. `owner`, `member`,
            `viewer`. A rule-group member with no explicit membership row reports
            `member`. Null when the caller has no role in it.
        member_count:
          type: integer
          description: Explicit memberships on the workspace.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceTemplate:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
          nullable: true
        icon:
          type: string
          nullable: true
        color:
          type: string
          nullable: true
        system_template:
          type: boolean
          description: True for the platform-provided set, false for the tenant's
            own.
        slug:
          type: string
          nullable: true
          description: Stable handle for the template. Accepted by `GET /workspace/templates/{id}`
            and by `template` on `POST /workspace/workspaces`. Unique among the system
            set only — a tenant's own template may reuse a system slug, in which case
            the system template wins a slug lookup and the numeric `id` is the unambiguous
            handle.
        kind:
          type: string
          description: The workspace `kind` a workspace created from this template
            starts with (the caller may override it on create). One of the values
            listed on `POST /workspace/workspaces` `kind`.
          example: project
        kind_label:
          type: string
          description: Human-readable label for `kind`, e.g. "Client account".
        industries:
          type: array
          items:
            type: string
          description: Industries the template gallery files it under. `[]` when none.
        use_cases:
          type: array
          items:
            type: string
          description: Use cases the template is written for. `[]` when none.
        featured:
          type: boolean
          description: True when the template is featured in the gallery.
        hero_summary:
          type: string
          nullable: true
          description: The gallery's one-paragraph pitch for the template.
        structure:
          type: object
          description: The blueprint a workspace created from this template receives
            — sections, task lists and seed content. `{}` when the template defines
            none.
        setup:
          type: object
          description: What `POST /workspace/workspaces` with this template asks for.
          properties:
            scheduled:
              type: boolean
              description: True when the template's to-dos carry day offsets — send
                `anchor_date` to date them.
            anchor_label:
              type: string
              description: What the anchor date is, e.g. "Go-live date", "Opening
                day".
            roles:
              type: array
              description: The template's role slots, filled with `roles` on create.
              items:
                type: object
                properties:
                  key:
                    type: string
                  name:
                    type: string
                  description:
                    type: string
                    nullable: true
                  defaults_to_creator:
                    type: boolean
                    description: True when an unsent slot is held by the caller.
        created_at:
          type: string
          format: date-time
    WorkspaceTemplatePlan:
      type: object
      description: A template's dated plan, as `POST /workspace/workspaces` would
        write it.
      properties:
        scheduled:
          type: boolean
        anchor_label:
          type: string
        anchor_date:
          type: string
          format: date
          nullable: true
        roles:
          type: array
          items:
            type: object
            properties:
              key:
                type: string
              name:
                type: string
              description:
                type: string
                nullable: true
              defaults_to_creator:
                type: boolean
        rows:
          type: array
          description: Every to-do and milestone, earliest first; undated rows last.
          items:
            type: object
            properties:
              section:
                type: string
                description: The task list it lands in ("Milestones" for milestones).
              title:
                type: string
              milestone:
                type: boolean
              role:
                type: string
                nullable: true
                description: The role slot key that owns it.
              start_offset_days:
                type: integer
                nullable: true
                description: Days from the anchor to its start (negative = before).
              due_offset_days:
                type: integer
                nullable: true
                description: Days from the anchor to its due day (negative = before).
              start_date:
                type: string
                format: date
                nullable: true
              due_date:
                type: string
                format: date
                nullable: true
    WorkspaceMessage:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        title:
          type: string
        body:
          type: string
        external_id:
          type: string
          nullable: true
          description: Caller-owned durable publication identity when one was supplied.
        internal_only:
          type: boolean
          description: Excluded from the external digest and external access when
            true.
        pinned:
          type: boolean
        kind:
          type: string
          enum:
          - standard
          - announcement
          - weekly_recap
          - closeout_recap
          - meeting_notes
          - status_update
          description: A `status_update` is an owner's project status update (PM-3).
        health:
          type: string
          nullable: true
          enum:
          - on_track
          - at_risk
          - off_track
          -
          description: The health the owner gave the project in a status update; null
            on every other kind.
        visible_to:
          type: string
          enum:
          - members
          - owners
          description: 'Who may read the post: `members` (everyone in the workspace)
            or `owners` (leads only — the workspace owners and Workspace app admins;
            absent for everyone else).'
        cascaded_from_id:
          type: integer
          nullable: true
          description: When this post is a cascaded copy, the id of the post it was
            copied from (e.g. a district post cascaded to its stores). Null for an
            original post, or when the source post has since been deleted.
        review_status:
          type: string
          enum:
          - published
          - pending_review
          - rejected
          description: Posting-policy state. `published` is live. `pending_review`
            is held for the workspace owner (or a Workspace app admin) to approve;
            `rejected` was turned down with `review_notes`. A held post is readable
            only by its author and the reviewers.
        review_notes:
          type: string
          nullable: true
          description: The reviewer's reason while the post is `rejected`; null otherwise
            (it is cleared on approval and when the author's edit sends the post back
            for review).
        author:
          "$ref": "#/components/schemas/WorkspaceUserRef"
        comment_count:
          type: integer
          description: Comments on the thread. On the detail endpoint this is the
            TRUE total, not the size of the `comments` page returned beside it.
        web_url:
          type: string
          format: uri
          description: Browser URL for the message in the tenant.
        attachments:
          type: array
          description: Present on the detail, create, edit, approve/reject and attachment
            responses; omitted from the message-board list.
          items:
            "$ref": "#/components/schemas/WorkspaceMessageAttachment"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        last_reviewed_at:
          type: string
          format: date-time
          nullable: true
          description: When someone last marked the post as still accurate (`POST
            …/messages/{id}/mark-reviewed`); `null` until then. A check never moves
            `updated_at`.
    WorkspaceMessageAttachment:
      type: object
      properties:
        id:
          type: integer
        filename:
          type: string
        content_type:
          type: string
        byte_size:
          type: integer
        checksum:
          type: string
          description: Base64-encoded Active Storage checksum for durable client-side
            deduplication.
        inline_playable:
          type: boolean
        inline_url:
          type: string
          format: uri
        download_url:
          type: string
          format: uri
        created_at:
          type: string
          format: date-time
    WorkspaceMessageComment:
      type: object
      properties:
        id:
          type: integer
        body:
          type: string
        internal_only:
          type: boolean
        author:
          type: object
          description: 'The commenter. A reply posted through Workspace external access
            has no platform account: `id` is null, `email` is the client''s address
            and `external` is true.'
          properties:
            id:
              type: integer
              nullable: true
            email:
              type: string
              nullable: true
            name:
              type: string
              nullable: true
            external:
              type: boolean
        created_at:
          type: string
          format: date-time
    WorkspaceTask:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        list_id:
          type: integer
          nullable: true
          description: The task list this task sits in.
        title:
          type: string
        notes:
          type: string
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
        completed:
          type: boolean
        assignee:
          allOf:
          - "$ref": "#/components/schemas/WorkspaceUserRef"
          nullable: true
          description: Null when the task is unassigned.
        ai_assisted:
          type: boolean
          description: True when the task was proposed by the Workspace agent and
            a person accepted it — the platform's AI-assisted mark. Told to the caller
            under the tenant's reader setting ("Show readers when AI helped write
            a message", a company-wide setting); the person who added the task and
            the workspace's owners and admins always see it.
        ai_drafted:
          type: boolean
          deprecated: true
          description: The old name of `ai_assisted`, with the same value. Removed
            after 2026-10-15.
        internal_only:
          type: boolean
        recurring_cadence:
          type: string
          nullable: true
        start_date:
          type: string
          format: date
          nullable: true
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
        estimated_duration_minutes:
          type: integer
          nullable: true
        milestone:
          type: boolean
        parent_task_id:
          type: integer
          nullable: true
          description: Set on a subtask — the to-do it sits under.
        unfilled_role:
          type: string
          nullable: true
          description: The project template's role slot this to-do was made for ("IT
            lead") while nobody is assigned; null once someone is.
        collaborator_ids:
          type: array
          items:
            type: integer
          description: The collaborators beside the one accountable owner (`assignee`).
        custom_fields:
          type: object
          additionalProperties: true
          description: This workspace's custom to-do fields, `field_name` → stored
            value.
        waits_on:
          type: array
          description: The to-dos this one waits on (PM-2a, finish-to-start).
          items:
            type: object
            properties:
              dependency_id:
                type: integer
              task_id:
                type: integer
                description: The to-do it waits on.
              lag_days:
                type: integer
                description: Days after that one finishes; negative overlaps.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceCheckIn:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        question:
          type: string
        kind:
          type: string
          enum:
          - question
          - status_update
          description: "`status_update` is the owner-only reminder to post a project
            status update (PM-3); its answer is posted as a status update."
        cadence:
          type: string
          description: How often the question is asked, e.g. `weekly`.
        active:
          type: boolean
        next_run_at:
          type: string
          format: date-time
          nullable: true
        time_of_day:
          type: string
          nullable: true
          description: The chosen opening time, 24-hour `HH:MM`, read in `timezone`;
            null when the check-in opens at the hour it was created.
        days_of_week:
          type: array
          items:
            type: integer
          description: For a weekly check-in with a chosen time, the weekdays it asks
            on (0 = Sunday … 6 = Saturday).
        timezone:
          type: string
          nullable: true
          description: The time zone `time_of_day` is read in.
        answer_type:
          type: string
          enum:
          - text
          - select
          - boolean
          - number
          - date
          description: What members answer with. `text` is a written answer (every
            check-in created before typed answers, and the status-update reminder,
            always). The typed ones — `select` (pick one from `answer_options.choices`),
            `boolean` (yes / no), `number` and `date` — are tallied per cycle on the
            check-in page and are never AI-drafted.
        answer_options:
          type: object
          description: 'The answer type''s settings: `choices` for `select`, the optional
            `min` / `max` for `number` (1 and 5 make a rating), empty for the other
            types.'
          properties:
            choices:
              type: array
              items:
                type: string
              description: The options members pick from, in order (2–20).
            min:
              type: number
              description: The lowest number allowed, when set.
            max:
              type: number
              description: The highest number allowed, when set.
        created_at:
          type: string
          format: date-time
    WorkspaceCheckInDraft:
      type: object
      description: An AI-drafted check-in response of the caller's, awaiting approval.
      properties:
        id:
          type: integer
          description: Pass this as `response_id` to the approve endpoint.
        question:
          type: string
          nullable: true
          description: The check-in question this drafts an answer to.
        response_text:
          type: string
          nullable: true
        answer_value:
          type: string
          nullable: true
          description: A typed answer's value. Always null here — only check-ins that
            take a written answer (`answer_type` `text`) are drafted.
        drafted_at:
          type: string
          format: date-time
          nullable: true
        ai_assisted:
          type: boolean
          description: Always true here — every draft is one the Workspace agent wrote.
        ai_drafted:
          type: boolean
          deprecated: true
          description: The old name of `ai_assisted`, with the same value. Removed
            after 2026-10-15.
    WorkspaceEvent:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        title:
          type: string
        location:
          type: string
          nullable: true
          description: Free text; this is where meeting links usually live.
        all_day:
          type: boolean
        starts_at:
          type: string
          format: date-time
          description: For an all-day event, local midnight of its date in the caller's
            time zone; read start_date for the date itself.
        ends_at:
          type: string
          format: date-time
          nullable: true
        start_date:
          type: string
          format: date
          nullable: true
          description: The calendar date of an all-day event (null for timed events).
        end_date:
          type: string
          format: date
          nullable: true
          description: The inclusive last date of an all-day event (null for timed
            events).
        internal_only:
          type: boolean
          description: Excluded from the external digest and external access when
            true. Carried so a client-facing calendar can filter before publishing.
        created_at:
          type: string
          format: date-time
    WorkspaceHillChart:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        scope_label:
          type: string
          description: The named piece of work this chart tracks.
        position:
          type: integer
          description: Position on the hill, 0-100. The uphill half is "figuring it
            out", the downhill half "making it happen".
        status:
          type: string
          enum:
          - active
          - archived
        ai_proposed_position:
          type: integer
          nullable: true
          description: The agent's suggested position, when one is pending.
        ai_proposal_pending:
          type: boolean
          description: True when a suggestion is awaiting a human decision.
        updated_by_agent_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    WorkspaceHealthSignals:
      type: object
      description: What the data shows about a project (PM-3), beside the owner's
        own `health`.
      properties:
        overdue_tasks:
          type: integer
        check_in_due:
          type: boolean
          description: The owner has not answered this cycle's status-update check-in.
        missed_milestones:
          type: integer
        open_risks:
          type: integer
        high_risks:
          type: integer
          description: Open risks rated high or critical.
        tone:
          type: string
          description: The overall tone the signals give (for a badge).
    WorkspaceCustomField:
      type: object
      properties:
        id:
          type: integer
        field_name:
          type: string
          description: The key a to-do's `custom_fields` uses.
        label:
          type: string
        field_type:
          type: string
          enum:
          - text
          - long_text
          - number
          - currency
          - date
          - select
          - boolean
          - email
          - url
          - user
        choices:
          type: array
          nullable: true
          items:
            type: string
          description: A `select` field's options; null for every other type.
        position:
          type: integer
    WorkspaceRisk:
      type: object
      properties:
        id:
          type: integer
        workspace_id:
          type: integer
        title:
          type: string
        description:
          type: string
          nullable: true
        category:
          type: string
          enum:
          - safety
          - quality
          - environmental
          - security
          - operational
          - financial
          - legal
          - other
        status:
          type: string
          enum:
          - identified
          - assessing
          - mitigating
          - monitoring
          - accepted
          - closed
        likelihood:
          type: integer
          minimum: 1
          maximum: 5
        impact:
          type: integer
          minimum: 1
          maximum: 5
        score:
          type: integer
          description: likelihood × impact.
        rating:
          type: string
          enum:
          - low
          - medium
          - high
          - critical
        owner_id:
          type: integer
          nullable: true
        mitigation_plan:
          type: string
          nullable: true
        review_frequency:
          type: string
          nullable: true
          description: Returned only where Compliance Hub is enabled for the business.
          enum:
          - monthly
          - quarterly
          - semiannual
          - annual
          - ad_hoc
          -
        review_on:
          type: string
          format: date
          nullable: true
          description: Returned only where Compliance Hub is enabled for the business.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceRiskInput:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        category:
          type: string
          enum:
          - safety
          - quality
          - environmental
          - security
          - operational
          - financial
          - legal
          - other
        status:
          type: string
          enum:
          - identified
          - assessing
          - mitigating
          - monitoring
          - accepted
          - closed
        likelihood:
          type: integer
          minimum: 1
          maximum: 5
        impact:
          type: integer
          minimum: 1
          maximum: 5
        owner_id:
          type: integer
          description: An active member of the project.
        mitigation_plan:
          type: string
        review_on:
          type: string
          format: date
        review_frequency:
          type: string
          enum:
          - monthly
          - quarterly
          - semiannual
          - annual
          - ad_hoc
    WorkspaceDependency:
      type: object
      description: A finish-to-start link (PM-2a) — the successor may start `lag_days`
        after the predecessor finishes.
      properties:
        id:
          type: integer
        successor_id:
          type: integer
          description: The to-do that waits.
        successor_title:
          type: string
        predecessor_id:
          type: integer
          description: The to-do it waits on.
        predecessor_title:
          type: string
        lag_days:
          type: integer
          description: Negative lets the two overlap.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WorkspaceScheduleConflict:
      type: object
      description: A to-do that starts before a to-do it waits on has finished (plus
        the gap).
      properties:
        dependency_id:
          type: integer
        predecessor_id:
          type: integer
        predecessor_title:
          type: string
        successor_id:
          type: integer
        successor_title:
          type: string
        lag_days:
          type: integer
        earliest_start:
          type: string
          format: date
          description: The first day the successor may start.
        days_early:
          type: integer
          description: How many days before that it starts now.
        movable:
          type: boolean
          description: False when the successor has already started — a shift leaves
            it where it is.
    WorkspaceScheduleMove:
      type: object
      description: One to-do moved later, whole, by a shift.
      properties:
        task_id:
          type: integer
        title:
          type: string
        days:
          type: integer
        start_from:
          type: string
          format: date
          nullable: true
        start_to:
          type: string
          format: date
          nullable: true
        due_from:
          type: string
          format: date
          nullable: true
        due_to:
          type: string
          format: date
          nullable: true
        subtasks:
          type: array
          description: The to-do's open, not-started, dated subtasks, moved by the
            same days.
          items:
            "$ref": "#/components/schemas/WorkspaceScheduleMove"
    WorkspaceTimelineTask:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        status:
          type: string
          enum:
          - pending
          - in_progress
          - overdue
          - completed
        completed:
          type: boolean
        milestone:
          type: boolean
        priority:
          type: string
          enum:
          - normal
          - important
          - critical
        start_date:
          type: string
          format: date
          nullable: true
        due_at:
          type: string
          format: date-time
          nullable: true
        due_on:
          type: string
          format: date
          nullable: true
          description: Set when the to-do is due on a day rather than at a time.
        starts_on:
          type: string
          format: date
          description: The first day of its span.
        ends_on:
          type: string
          format: date
          description: The last day of its span.
        assignee:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            name:
              type: string
        waits_on:
          type: array
          items:
            type: object
            properties:
              dependency_id:
                type: integer
              task_id:
                type: integer
              lag_days:
                type: integer
    WorkspaceReaction:
      type: object
      properties:
        id:
          type: integer
        emoji:
          type: string
        reactable_type:
          type: string
          enum:
          - WorkspaceMessage
          - WorkspaceMessageComment
          - Task
        reactable_id:
          type: integer
        user:
          "$ref": "#/components/schemas/WorkspaceUserRef"
        created_at:
          type: string
          format: date-time
tags:
- name: People Changes
  description: HR write-back receiver API. A write-back webhook (event people.change)
    carries only a reference to an approved profile change; the receiving middleware
    fetches the change with read:people_changes and reports whether it was applied
    with write:people_changes. Administrator tokens only.
- name: Upload Sessions
  description: Chunked, resumable uploads for large files (over 200 MB) into Drive,
    AI Notepad and Recorder. Every byte goes through the API in bounded parts — no
    presigned URLs — so a dropped connection costs one part, not the file.
- name: Branding
  description: The business's theme tokens, logo, brand fonts and colour palette,
    for a client that themes itself at runtime.
- name: Tasks
  description: Personal tasks for One Mango's own clients — the JSON twin of the web's
    My Tasks quick-create drawer. Always the caller's own task.
- name: Desktop
  description: OneMango Desktop — the one boot answer (rail, every app's boot block,
    branding) the desktop app draws itself from right after login. Composed from the
    same builders the per-app endpoints render, so the two cannot drift; never 403s
    (an empty landing is the "nothing is switched on for the desktop" answer).
- name: Workspace
  description: Workspace app — project workspaces and their canvas sections (message
    board, tasks, recurring check-ins, schedule, hill charts, reactions), plus the
    workspace templates and the external digest. API tokens require read:workspace
    for GET/HEAD and write:workspace for mutations; app, tenant, membership, role,
    and feature checks still apply.
- name: Frontline Execution
  description: Frontline Execution worker surface — shift pass-down (handover between
    shifts)
- name: Lone Worker
  description: Lone Worker — a worker's own safety status, SOS, floor-issue report
    and check-in answer, and the responder's alert feed with acknowledge and resolve.
    Reads need read:lone_worker, writes write:lone_worker (the own_ forms are accepted).
- name: Live Boards
  description: Live Boards — boards with their current readings, and the rotations
    built from them (read-only; the app is metered on active board objects, so no
    write endpoints are offered)
- name: Surveys
  description: Surveys app — the surveys assigned to the caller, a survey with its
    questions, submitting a response, the anonymous feedback channel, and anonymity-safe
    aggregate results plus reminders for the people who run a survey. Per-survey authorization
    is Surveys::AccessPolicy, so the results payload differs by tier; raw per-respondent
    answers are never returned.
- name: AI Interviews
  description: 'The caller''s own conversations with the AI interviewer — Offboarding''s
    "Talk it through", Surveys'' conversation mode, Shift Feedback''s check-in — action
    for action with the conversation page: start or resume through the app''s own
    door, the opening (the consent), typed, tapped and spoken answers, skip, the review
    ("Here''s what we''ll record"), send and delete. Named conversations only: an
    anonymous one is held on the web (`anonymous_on_web`). Live voice is a browser
    transport and is not offered here. Reads need read:ai_interviews, writes write:ai_interviews
    (the own_ forms are accepted; the app''s sign-in tokens carry them).'
- name: Ideas
  description: 'Ideas — dashboard, lists and search, idea detail, votes, comments,
    scores, stage moves and campaigns. The API token''s scope (owner decision 2026-10-06,
    #6720): `read:ideas` to read, `write:ideas` to change anything, and `destructive:ideas`
    to delete an idea, a campaign or a comment, close a campaign, or move an idea
    into a declined stage (on top of `write:ideas`) — and, also on top of `write:ideas`,
    to edit a running campaign''s close date to a day before today, which closes it
    (#7186). The sign-in tokens hold all three.'
- name: Comms Hub
  description: Communications (Comms Hub / News Feed) — the home dashboard (unread/ack/verified
    counts, open poll, latest posts, must-read status, trending topics), newsletters
    (publications and issues), audiences, and campaigns (the Campaign field's choices,
    and putting a message in one)
- name: Recognitions
  description: Recognitions app dashboard — received/given/nomination stats, the activity
    stream, my awards, my nominations, trending recognition, active programs, the
    running award cycle and this month's top recipients (read-only)
- name: Company Store
  description: 'Company Store app — the employee rewards storefront. Dashboard: the
    wallet, the four stat tiles, the "within reach" affordability carousel, featured
    and saved items, the 30-day points-activity roll-up, recent orders, the first-run
    earn on-ramp and the manager-only team-approvals widget. Catalog: the paginated
    grid with the web page''s own category / collection / featured / search filters,
    its six sort strategies and region scoping, the filter sheet with counts, and
    one item''s detail (photo strip, variant groups, per-method payment availability,
    Item Details, related strip). Orders: order history with per-status counts and
    one order''s full detail (derived lifecycle timeline, reward payload, shipping,
    available actions). Approvals: the redemption approval queue plus its approve
    / decline decisions — the one write surface here, and the one endpoint group open
    ONLY to redemption approvers (a designated approver-group member, or a line manager
    with direct reports when no group is configured). Everything else is read-only'
- name: Wikis
  description: 'Wikis knowledge base — dashboard, browse tree, search, pages, comments,
    reactions and bookmarks. The API token''s scope (owner decision 2026-10-04, #6720):
    `read:wikis` to read, `write:wikis` to change anything, and `destructive:wikis`
    to delete or archive a wiki or delete a comment. The sign-in tokens hold all three.'
- name: Sites
  description: 'Sites app — company intranet sites and their pages. The reader''s
    own writes and lists: follow a site, watch a page, read and post a page''s comments,
    and the caller''s Saved & following lists (following, watching, recently read).
    POST only adds and DELETE only removes (never a toggle); DELETE is never gated
    on still having access, so nobody is stranded in a subscription to a site they
    lost.'
- name: Search
  description: 'One ranked search across everything the caller can see — the web results
    page (/apps/search) as data: the same engine, ranking, live answers, filters (In
    · When · Only mine) and permissions, plus the page before you type (saved and
    recent searches) when no query is given — and the caller''s saved searches (save,
    list, remove), and the lookahead listed while the caller is still typing (the
    omnibox''s lookahead).'
- name: Libraries
  description: 'Libraries app — curated link / file / media collections. The All Libraries
    index mirrors the web page card-for-card (cover, mark, kind, category and item
    counts, last-updated, capability flags) with the all / disabled filters, the three
    orders (Admin order · A → Z · Recently updated) and pagination; disabled libraries
    always sort last. Visibility is audience-scoped — a member never sees a disabled
    library or one whose audience rules exclude them. Libraries administrators can
    additionally DISABLE a library (POST /libraries/{id}/disable) — a reversible hide
    that mirrors the web control, is idempotent rather than a toggle, and requires
    the `write:libraries` scope. The library detail read (GET /libraries/{id}) returns
    the whole library screen in one call — the header, every category with the layout
    and sort order its admin set on it, and every item with its format, provenance,
    resolved destination, download and pre-gated kebab actions. It is reachable only
    by a caller who can access that library: a restricted, disabled-and-unmanageable,
    cross-tenant or non-existent id all return the same 404.'
- name: Training
  description: Training app learner "My Training" home — stats, continue learning,
    upcoming instructor-led sessions, and the status-filtered course-enrollment list
    (read-only)
- name: Leader Rounds
  description: Leader Rounds — structured leader rounding on staff. The due list,
    the capture form's typed question set, round detail across both visibility tiers,
    the derived stoplight issue ledger, coverage rolled up the reporting line, and
    the subject-tier close-the-loop view (rounds about you, what you raised, what
    you own).
- name: Authentication
  description: Mobile app authentication endpoints
- name: Two-Factor Authentication
  description: Two-factor authentication self-service management
- name: Passwordless Authentication
  description: Passwordless authentication via magic links and codes
- name: API Token Management
  description: Personal API token creation and management
- name: Impersonation
  description: Impersonation token management for service accounts to act on behalf
    of users
- name: Service Desk
  description: Support ticket management and help desk operations
- name: RFP Desk
  description: RFP Desk — answer library, RFP responses and their sections, and bid/no-bid
    assessments
- name: Employee Compensation
  description: Employee compensation profile, history, and insights management
- name: Compensation Requests
  description: Compensation change request lifecycle management
- name: Users
  description: User profile and preferences management
- name: User Profile Management
  description: Enhanced user profile management with actions and bulk operations
- name: Account Settings
  description: Comprehensive account settings management including security, privacy,
    and communication preferences
- name: Leave Management
  description: Employee leave requests, balances, and leave type management
- name: Forms
  description: Form templates, submissions, and mobile rendering with offline support
- name: Marketplace Apps
  description: Enabled marketplace apps for mobile app launcher
- name: System Modules
  description: Core platform features that can be enabled/disabled per business
- name: Skills & Certifications
  description: Employee skills management, certifications, and competency tracking
- name: Shifts
  description: Shift management and scheduling
- name: Attendance
  description: Time tracking and attendance management
- name: Timesheets
  description: Employee timesheet management and submission
- name: Timesheet Entries
  description: Individual timesheet entry management and editing
- name: Pay Information
  description: Employee pay summaries, history, and year-to-date information
- name: Paychecks
  description: Employee paycheck access and detailed pay stub information
- name: Pay Periods
  description: Pay period management and detailed period information
- name: Manual Time Entries
  description: Manual time entry creation and management for missed punches
- name: EPMS Dashboard
  description: Employee Performance Management System dashboard with aggregated goals,
    reviews, feedback, and meetings
- name: EPMS Goals
  description: Employee goal management including creation, tracking, progress updates,
    and completion
- name: EPMS Performance Reviews
  description: Performance review lifecycle management including creation, self-assessments,
    approvals, and goal linking
- name: EPMS Continuous Feedback
  description: Continuous feedback system for real-time performance feedback including
    praise, recognition, and coaching
- name: EPMS Development Plans
  description: Employee development plan management with goal tracking and skill development
- name: EPMS Meetings
  description: One-on-one meetings and check-ins scheduling and management
- name: EPMS Competency Frameworks
  description: Competency framework definitions and structure (read-only for most
    users)
- name: EPMS Competency Assessments
  description: Competency assessment creation and management with ratings and evaluations
- name: My Team
  description: What the caller's team still owes, rolled up per direct report (read:people_directory)
- name: Share
  description: Send a record the caller can open (a wiki, a Sites page, a Training
    course) to a chat room, a Teams or Slack channel, or a colleague, and take it
    back — the web Share drawer's rules.
- name: Shared Moments
  description: A person shares their own recognition, award, work anniversary, contest
    win, certificate or year in recognition outside the company as a card and a link
    — list, share, stop (the moment's app's read/write scope)
- name: Notifications
  description: 'User notification management — mark read/unread, archive, bulk operations
    (mobile support). A token needs `read:notifications` for every GET (the list,
    one item, the counts, the grouped and home views) and `write:notifications` for
    every change (mark read or unread, archive, restore, snooze, dismiss, delete,
    reply) and for an inline action that is not a decision (acknowledge, sign-off
    statements); a decision (approve, reject, resume, sign) needs `write:approvals`.
    Without the scope the answer is 403 `insufficient_permissions`. The native app''s
    sign-in tokens carry all three.

    '
- name: Messaging
  description: Inbox → Messages (Direct Messages) — 1:1 / small-group conversations,
    message compose (text + attachments), edit, delete, participants, and recipient
    picker
- name: Chat
  description: 'Chat app — direct / group / channel rooms, async message posting with
    media, reactions, threads, important-message acknowledgements, mentions, search,
    presence, and Pusher real-time bootstrap. Every operation answers 403 with `error.code`
    `app_not_enabled` / `app_not_accessible` when the caller has no Chat, and `insufficient_permissions`
    when the token lacks the scope: read:chat for GET, write:chat for changes, destructive:chat
    for final acts — deleting a message or a conversation, leaving one, removing a
    member, archiving a channel, closing a poll (read:own_chat / write:own_chat also
    satisfy the first two). An operation''s own 403 list names only its own refusals.'
- name: Weekly Reports
  description: Weekly Reports — this period's report, a quick-added line, Skip, and
    "I can help" on a teammate's ask. Reads need `read:weekly_reports`, changes `write:weekly_reports`.
    Every operation answers 403 `app_disabled` / `app_forbidden` when the caller has
    no Weekly Reports.
- name: Meal Compliance
  description: Meal Compliance — the Dashboard tab (waiver coverage, pending signatures,
    behavior alerts, employee withdrawals) and the Waivers register (status filters
    with counts, search, location, sort), and My Breaks (the caller's own waiver status,
    signing, break pattern and waiver history), and Break Compliance (the break-finding
    report — tiles, rules in force, All / Unresolved / Resolved findings)
- name: Safety Hub
  description: Incidents, safety observations, toolbox talks, certifications, and
    the personal submission feed
- name: Inspections
  description: Inspections — role-aware list endpoint (personal by default, team feed
    via `?team=true` for managers)
- name: News Feed
  description: 'News Feed posts (Update / Question / Poll), pinning, comments, reactions,
    read receipts, and must-read acknowledgements. The API token''s scope (owner decision
    2026-10-06, #6720): `read:news_feed` to read, `write:news_feed` to post, comment,
    react, vote or upload, and `destructive:news_feed` to delete a post, a comment
    or a file, close a poll or a discussion, or mark a verified answer outdated. Featuring
    a post asks `read:/write:news_feed_editorial_features`, and comment reports `read:/write:news_feed_reports`.
    The sign-in tokens hold all three News Feed scopes; a token a person makes by
    hand holds read and write. After the scope, every News Feed endpoint (all but
    `/admin/news-feed/settings`) answers only where Communications is on for the business
    and open to the caller, under the admin''s Communications access policy: otherwise
    403 with `error.code` `app_disabled`, `app_unavailable` or `work_context_restricted`,
    before anything is read or written (#7173).'
- name: Broadcasts
  description: Company broadcasts — the caller's received inbox (Dashboard parity)
    with all/unread/critical/acknowledge subfilters, plus create/publish/acknowledge
- name: Alerts
  description: Emergency alerts — the caller's received inbox + pending approvals,
    with all/urgent/acknowledge/safety_check_in subfilters and per-caller response
    state
- name: Approvals
  description: Shared approve/reject for Comms Hub approval requests — one endpoint
    pair for both broadcasts and alerts (act on the request id from a source's pending_approvals)
- name: Audience Options
  description: '"Add to audience" picker options shared by the broadcast + alert composers
    — departments / locations / job titles / roles / groups, each with a user_count
    (search + pagination)'
- name: Webhooks
  description: Register, test and manage the signed webhooks a business's events are
    delivered to (admin tokens holding the webhooks scopes)
- name: Forecasts
  description: Per-day, per-location staffing forecasts with hourly predictions —
    create, bulk-create, read by date range, update and delete
- name: Demand Signals
  description: Events, weather, holidays and promotions that change demand at a location
    on a date, and feed forecasts
- name: Export Files
  description: Background CSV/JSON/XLSX exports of people, shifts, assignments, attendance
    and leave — start one, poll its status, list and download with a signed link
- name: Live Assist
  description: Read-only Live Assist video sessions the caller may see — the list
    and one session
- name: Policy Hub
  description: Policies and handbooks published to the caller, their acknowledgements,
    and acknowledging a policy
- name: SOP Hub
  description: The caller's SOPs to complete, reading and working through a procedure
    step by step, and the manuals open to them
- name: Locations
  description: The caller's assigned work locations and a location's departments
- name: Recipient Group Parameters
  description: The lists a recipient group's send-time parameters are picked from
    (locations, departments, people), and which parameters a group needs
- name: Practice Hub
  description: Read-only Practice Hub — the business's published scenario library
    and the caller's own practice attempts
- name: Muscle Memory
  description: 'Muscle Memory — the lessons a business has learned. Read and search
    them, read the aggregate learning picture, add a lesson, and curate one: confirm
    it, correct it with a note, reinstate a superseded one, or forget it. Forgetting
    (DELETE) is permanent — the lesson is archived, never listed again and never re-learned,
    and nothing restores it. Scopes read:muscle_memory, write:muscle_memory and, for
    forgetting, destructive:muscle_memory.'
- name: Asset Pro
  description: One Asset Pro asset (equipment) for its holder, an Asset Pro admin
    or the holder's manager
- name: Appointment Scheduler
  description: The caller's booking pages and the appointments booked on them — list
    and read them, preview availability, and cancel or move an appointment
- name: Bookings
  description: Book rooms, desks and other spaces; list, change, cancel, check in
    and out of bookings, and read the bookable spaces
- name: Office Integration
  description: The API behind the MangoApps Word/Excel add-in — the manifest, the
    app picker, and finding, opening, downloading, revising and acting on records
- name: Intelligence
  description: The caller's AI-assistant personalisation (smart defaults, suggestions,
    usage patterns, feedback) and admin-only AI analytics
- name: Employees
  description: A colleague's directory card and a person's shift schedule
- name: Recent Entities
  description: The most recently updated employees, shifts or locations of the business,
    for quick pickers
- name: Notes
  description: Add a Performance (EPMS) note to a review, goal, 1:1 meeting, development
    plan or person
- name: AI Notepad
  description: AI Notepad — meetings and notes with their transcripts, AI artifacts,
    action items, notebooks, sharing and streamed AI chat. A row is both a recorded
    meeting and a plain typed note; `source` says which. Bearer token; this namespace
    declares no per-endpoint token scopes — the gate is that the AI Notepad app is
    enabled for the business and visible to the caller — except send_to_chat, which
    also needs write:chat.
- name: AI Front Office
  description: The dispatcher's booking-request review queue (decline).
- name: Analytics
  description: Workforce sentiment alerts (dismiss).
- name: Bulk Operations
  description: Queued batch writes to shifts, assignments, availabilities, preferences,
    demand signals and forecasts.
- name: Calendar
  description: Calendar events (delete).
- name: Classifieds
  description: Classified listings (remove).
- name: Mango Signal
  description: Actions taken on engagement signals (update, dismiss).
- name: Mango Spend
  description: Expense reports and their approval (reject, remove a line).
- name: Password Manager
  description: Password entries (delete, archive).
- name: Recorder
  description: Screen recordings, their annotations, comments, shares and public links.
- name: Recruiting
  description: Recruiting candidates (update status).
